mirror of
https://github.com/home-assistant/core.git
synced 2026-02-25 03:31:15 +01:00
Compare commits
5 Commits
dev
...
use-unix-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6be7a12b1 | ||
|
|
72db92b17b | ||
|
|
c5889082c0 | ||
|
|
68d94badc6 | ||
|
|
275374ec0d |
20
.github/workflows/builder.yml
vendored
20
.github/workflows/builder.yml
vendored
@@ -272,7 +272,7 @@ jobs:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
@@ -294,21 +294,6 @@ jobs:
|
||||
- raspberrypi5-64
|
||||
- yellow
|
||||
- green
|
||||
include:
|
||||
# Default: aarch64 on native ARM runner
|
||||
- arch: aarch64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# Overrides for amd64 machines
|
||||
- machine: generic-x86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
- machine: qemux86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
|
||||
- machine: intel-nuc
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -336,9 +321,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
|
||||
3
CODEOWNERS
generated
3
CODEOWNERS
generated
@@ -555,6 +555,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
|
||||
/tests/components/fritzbox/ @mib1185 @flabbamann
|
||||
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
|
||||
/tests/components/fritzbox_callmonitor/ @cdce8p
|
||||
/homeassistant/components/fronius/ @farmio
|
||||
/tests/components/fronius/ @farmio
|
||||
/homeassistant/components/frontend/ @home-assistant/frontend
|
||||
@@ -1966,7 +1968,6 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/zone/ @home-assistant/core
|
||||
/tests/components/zone/ @home-assistant/core
|
||||
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
|
||||
/tests/components/zoneminder/ @rohankapoorcom @nabbi
|
||||
/homeassistant/components/zwave_js/ @home-assistant/z-wave
|
||||
/tests/components/zwave_js/ @home-assistant/z-wave
|
||||
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS
|
||||
|
||||
@@ -4,16 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.airos6 import AirOS6
|
||||
from airos.airos8 import AirOS8
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSKeyDataMissingError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData, async_get_firmware_data
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -24,11 +15,6 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -53,40 +39,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
|
||||
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
conn_data = {
|
||||
CONF_HOST: entry.data[CONF_HOST],
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
CONF_PASSWORD: entry.data[CONF_PASSWORD],
|
||||
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
"session": session,
|
||||
}
|
||||
|
||||
# Determine firmware version before creating the device instance
|
||||
try:
|
||||
device_data: DetectDeviceData = await async_get_firmware_data(**conn_data)
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDeviceConnectionError,
|
||||
TimeoutError,
|
||||
) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
except (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSDataMissingError,
|
||||
) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except AirOSKeyDataMissingError as err:
|
||||
raise ConfigEntryError("key_data_missing") from err
|
||||
except Exception as err:
|
||||
raise ConfigEntryError("unknown") from err
|
||||
|
||||
airos_class: type[AirOS8 | AirOS6] = (
|
||||
AirOS8 if device_data["fw_major"] == 8 else AirOS6
|
||||
airos_device = AirOS8(
|
||||
host=entry.data[CONF_HOST],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
|
||||
airos_device = airos_class(**conn_data)
|
||||
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
|
||||
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
@@ -4,9 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import AirOSDataBaseClass
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -20,24 +18,25 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
from .entity import AirOSEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSBinarySensorEntityDescription(
|
||||
BinarySensorEntityDescription,
|
||||
Generic[AirOSDataModel],
|
||||
):
|
||||
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe an AirOS binary sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], bool]
|
||||
value_fn: Callable[[AirOS8Data], bool]
|
||||
|
||||
|
||||
AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data]
|
||||
|
||||
COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="portfw",
|
||||
translation_key="port_forwarding",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.portfw,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="dhcp_client",
|
||||
translation_key="dhcp_client",
|
||||
@@ -54,23 +53,6 @@ COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="pppoe",
|
||||
translation_key="pppoe",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.services.pppoe,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
|
||||
AirOS8BinarySensorEntityDescription(
|
||||
key="portfw",
|
||||
translation_key="port_forwarding",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.portfw,
|
||||
),
|
||||
AirOS8BinarySensorEntityDescription(
|
||||
key="dhcp6_server",
|
||||
translation_key="dhcp6_server",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
@@ -78,6 +60,14 @@ AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
|
||||
value_fn=lambda data: data.services.dhcp6d_stateful,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSBinarySensorEntityDescription(
|
||||
key="pppoe",
|
||||
translation_key="pppoe",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.services.pppoe,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -89,20 +79,10 @@ async def async_setup_entry(
|
||||
"""Set up the AirOS binary sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
entities: list[BinarySensorEntity] = []
|
||||
entities.extend(
|
||||
AirOSBinarySensor(coordinator, description)
|
||||
for description in COMMON_BINARY_SENSORS
|
||||
async_add_entities(
|
||||
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
|
||||
)
|
||||
|
||||
if coordinator.device_data["fw_major"] == 8:
|
||||
entities.extend(
|
||||
AirOSBinarySensor(coordinator, description)
|
||||
for description in AIROS8_BINARY_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
|
||||
"""Representation of a binary sensor."""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.exceptions import AirOSException
|
||||
|
||||
from homeassistant.components.button import (
|
||||
@@ -16,6 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
from .entity import AirOSEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
REBOOT_BUTTON = ButtonEntityDescription(
|
||||
|
||||
@@ -7,8 +7,6 @@ from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.airos6 import AirOS6
|
||||
from airos.airos8 import AirOS8
|
||||
from airos.discovery import airos_discover_devices
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
@@ -19,7 +17,6 @@ from airos.exceptions import (
|
||||
AirOSKeyDataMissingError,
|
||||
AirOSListenerError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData, async_get_firmware_data
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -37,13 +34,11 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.data_entry_flow import section
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
DEFAULT_SSL,
|
||||
@@ -56,11 +51,10 @@ from .const import (
|
||||
MAC_ADDRESS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
)
|
||||
from .coordinator import AirOS8
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
|
||||
# Discovery duration in seconds, airOS announces every 20 seconds
|
||||
DISCOVER_INTERVAL: int = 30
|
||||
|
||||
@@ -96,7 +90,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
super().__init__()
|
||||
self.airos_device: AirOSDeviceDetect
|
||||
self.airos_device: AirOS8
|
||||
self.errors: dict[str, str] = {}
|
||||
self.discovered_devices: dict[str, dict[str, Any]] = {}
|
||||
self.discovery_abort_reason: str | None = None
|
||||
@@ -139,14 +133,16 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
airos_device = AirOS8(
|
||||
host=config_data[CONF_HOST],
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
try:
|
||||
device_data: DetectDeviceData = await async_get_firmware_data(
|
||||
host=config_data[CONF_HOST],
|
||||
username=config_data[CONF_USERNAME],
|
||||
password=config_data[CONF_PASSWORD],
|
||||
session=session,
|
||||
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
|
||||
)
|
||||
await airos_device.login()
|
||||
airos_data = await airos_device.status()
|
||||
|
||||
except (
|
||||
AirOSConnectionSetupError,
|
||||
@@ -161,14 +157,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception during credential validation")
|
||||
self.errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(device_data["mac"])
|
||||
await self.async_set_unique_id(airos_data.derived.mac)
|
||||
|
||||
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
else:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return {"title": device_data["hostname"], "data": config_data}
|
||||
return {"title": airos_data.host.hostname, "data": config_data}
|
||||
|
||||
return None
|
||||
|
||||
@@ -396,18 +392,6 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Automatically handle a DHCP discovered IP change."""
|
||||
ip_address = discovery_info.ip
|
||||
# python-airos defaults to upper for derived mac_address
|
||||
normalized_mac = format_mac(discovery_info.macaddress).upper()
|
||||
await self.async_set_unique_id(normalized_mac)
|
||||
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
|
||||
return self.async_abort(reason="unreachable")
|
||||
|
||||
async def async_step_discovery_no_devices(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from airos.airos6 import AirOS6, AirOS6Data
|
||||
from airos.airos8 import AirOS8, AirOS8Data
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
@@ -12,7 +11,6 @@ from airos.exceptions import (
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
)
|
||||
from airos.helpers import DetectDeviceData
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -23,28 +21,19 @@ from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AirOSDeviceDetect = AirOS8 | AirOS6
|
||||
AirOSDataDetect = AirOS8Data | AirOS6Data
|
||||
|
||||
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
|
||||
"""Class to manage fetching AirOS data from single endpoint."""
|
||||
|
||||
airos_device: AirOSDeviceDetect
|
||||
config_entry: AirOSConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: AirOSConfigEntry,
|
||||
device_data: DetectDeviceData,
|
||||
airos_device: AirOSDeviceDetect,
|
||||
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.airos_device = airos_device
|
||||
self.device_data = device_data
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
@@ -53,7 +42,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AirOSDataDetect:
|
||||
async def _async_update_data(self) -> AirOS8Data:
|
||||
"""Fetch data from AirOS."""
|
||||
try:
|
||||
await self.airos_device.login()
|
||||
@@ -73,7 +62,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except AirOSDataMissingError as err:
|
||||
except (AirOSDataMissingError,) as err:
|
||||
_LOGGER.error("Expected data not returned by airOS device: %s", err)
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
"name": "Ubiquiti airOS",
|
||||
"codeowners": ["@CoMPaTech"],
|
||||
"config_flow": true,
|
||||
"dhcp": [{ "registered_devices": true }],
|
||||
"documentation": "https://www.home-assistant.io/integrations/airos",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.6.4"]
|
||||
}
|
||||
|
||||
@@ -42,20 +42,16 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: No way to detect device on the network
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -65,10 +61,8 @@ rules:
|
||||
status: exempt
|
||||
comment: no (custom) icons used or envisioned
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -5,14 +5,8 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import (
|
||||
AirOSDataBaseClass,
|
||||
DerivedWirelessMode,
|
||||
DerivedWirelessRole,
|
||||
NetRole,
|
||||
)
|
||||
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -43,19 +37,15 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describe an AirOS sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], StateType]
|
||||
value_fn: Callable[[AirOS8Data], StateType]
|
||||
|
||||
|
||||
AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data]
|
||||
|
||||
COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
AirOSSensorEntityDescription(
|
||||
key="host_cpuload",
|
||||
translation_key="host_cpuload",
|
||||
@@ -85,6 +75,54 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
translation_key="wireless_essid",
|
||||
value_fn=lambda data: data.wireless.essid,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wireless.antenna_gain,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_throughput_tx",
|
||||
translation_key="wireless_throughput_tx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.tx,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_throughput_rx",
|
||||
translation_key="wireless_throughput_rx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.rx,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_dl_capacity",
|
||||
translation_key="wireless_polling_dl_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.dl_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_ul_capacity",
|
||||
translation_key="wireless_polling_ul_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.ul_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="host_uptime",
|
||||
translation_key="host_uptime",
|
||||
@@ -120,57 +158,6 @@ COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
|
||||
options=WIRELESS_ROLE_OPTIONS,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_antenna_gain",
|
||||
translation_key="wireless_antenna_gain",
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda data: data.wireless.antenna_gain,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_dl_capacity",
|
||||
translation_key="wireless_polling_dl_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.dl_capacity,
|
||||
),
|
||||
AirOSSensorEntityDescription(
|
||||
key="wireless_polling_ul_capacity",
|
||||
translation_key="wireless_polling_ul_capacity",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.polling.ul_capacity,
|
||||
),
|
||||
)
|
||||
|
||||
AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = (
|
||||
AirOS8SensorEntityDescription(
|
||||
key="wireless_throughput_tx",
|
||||
translation_key="wireless_throughput_tx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.tx,
|
||||
),
|
||||
AirOS8SensorEntityDescription(
|
||||
key="wireless_throughput_rx",
|
||||
translation_key="wireless_throughput_rx",
|
||||
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
|
||||
device_class=SensorDeviceClass.DATA_RATE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
|
||||
value_fn=lambda data: data.wireless.throughput.rx,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -182,14 +169,7 @@ async def async_setup_entry(
|
||||
"""Set up the AirOS sensors from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AirOSSensor(coordinator, description) for description in COMMON_SENSORS
|
||||
)
|
||||
|
||||
if coordinator.device_data["fw_major"] == 8:
|
||||
async_add_entities(
|
||||
AirOSSensor(coordinator, description) for description in AIROS8_SENSORS
|
||||
)
|
||||
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
|
||||
|
||||
|
||||
class AirOSSensor(AirOSEntity, SensorEntity):
|
||||
|
||||
@@ -8,6 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ from typing import IO, Any, cast
|
||||
|
||||
import aiohttp
|
||||
from securetar import (
|
||||
InvalidPasswordError,
|
||||
SecureTarArchive,
|
||||
SecureTarError,
|
||||
SecureTarFile,
|
||||
@@ -166,7 +165,7 @@ def validate_password(path: Path, password: str | None) -> bool:
|
||||
):
|
||||
# If we can read the tar file, the password is correct
|
||||
return True
|
||||
except tarfile.ReadError, InvalidPasswordError, SecureTarReadError:
|
||||
except tarfile.ReadError, SecureTarReadError:
|
||||
LOGGER.debug("Invalid password")
|
||||
return False
|
||||
except Exception: # noqa: BLE001
|
||||
@@ -193,14 +192,13 @@ def validate_password_stream(
|
||||
for obj in input_archive.tar:
|
||||
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
|
||||
continue
|
||||
try:
|
||||
with input_archive.extract_tar(obj) as decrypted:
|
||||
if decrypted.plaintext_size is None:
|
||||
raise UnsupportedSecureTarVersion
|
||||
with input_archive.extract_tar(obj) as decrypted:
|
||||
if decrypted.plaintext_size is None:
|
||||
raise UnsupportedSecureTarVersion
|
||||
try:
|
||||
decrypted.read(1) # Read a single byte to trigger the decryption
|
||||
except (InvalidPasswordError, SecureTarReadError) as err:
|
||||
raise IncorrectPassword from err
|
||||
else:
|
||||
except SecureTarReadError as err:
|
||||
raise IncorrectPassword from err
|
||||
return
|
||||
raise BackupEmpty
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The BSB-LAN integration."""
|
||||
"""The BSB-Lan integration."""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
@@ -36,7 +36,7 @@ from .const import CONF_PASSKEY, DOMAIN
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -56,13 +56,13 @@ class BSBLanData:
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the BSB-LAN integration."""
|
||||
"""Set up the BSB-Lan integration."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
|
||||
"""Set up BSB-LAN from a config entry."""
|
||||
"""Set up BSB-Lan from a config entry."""
|
||||
|
||||
# create config using BSBLANConfig
|
||||
config = BSBLANConfig(
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Button platform for BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BSBLanConfigEntry, BSBLanData
|
||||
from .coordinator import BSBLanFastCoordinator
|
||||
from .entity import BSBLanEntity
|
||||
from .helpers import async_sync_device_time
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
BUTTON_DESCRIPTIONS: tuple[ButtonEntityDescription, ...] = (
|
||||
ButtonEntityDescription(
|
||||
key="sync_time",
|
||||
translation_key="sync_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: BSBLanConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up BSB-Lan button entities from a config entry."""
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
BSBLanButtonEntity(data.fast_coordinator, data, description)
|
||||
for description in BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class BSBLanButtonEntity(BSBLanEntity, ButtonEntity):
|
||||
"""Defines a BSB-Lan button entity."""
|
||||
|
||||
entity_description: ButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BSBLanFastCoordinator,
|
||||
data: BSBLanData,
|
||||
description: ButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BSB-Lan button entity."""
|
||||
super().__init__(coordinator, data)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
|
||||
self._data = data
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press."""
|
||||
await async_sync_device_time(self._data.client, self._data.device.name)
|
||||
@@ -39,15 +39,15 @@ PRESET_MODES = [
|
||||
PRESET_NONE,
|
||||
]
|
||||
|
||||
# Mapping from Home Assistant HVACMode to BSB-LAN integer values
|
||||
# BSB-LAN uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
|
||||
# Mapping from Home Assistant HVACMode to BSB-Lan integer values
|
||||
# BSB-Lan uses: 0=off, 1=auto, 2=eco/reduced, 3=heat/comfort
|
||||
HA_TO_BSBLAN_HVAC_MODE: Final[dict[HVACMode, int]] = {
|
||||
HVACMode.OFF: 0,
|
||||
HVACMode.AUTO: 1,
|
||||
HVACMode.HEAT: 3,
|
||||
}
|
||||
|
||||
# Mapping from BSB-LAN integer values to Home Assistant HVACMode
|
||||
# Mapping from BSB-Lan integer values to Home Assistant HVACMode
|
||||
BSBLAN_TO_HA_HVAC_MODE: Final[dict[int, HVACMode]] = {
|
||||
0: HVACMode.OFF,
|
||||
1: HVACMode.AUTO,
|
||||
@@ -69,6 +69,7 @@ async def async_setup_entry(
|
||||
class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
"""Defines a BSBLAN climate device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
# Determine preset modes
|
||||
_attr_supported_features = (
|
||||
@@ -137,7 +138,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
# BSB-LAN mode 2 is eco/reduced mode
|
||||
# BSB-Lan mode 2 is eco/reduced mode
|
||||
if self._hvac_mode_value == 2:
|
||||
return PRESET_ECO
|
||||
return PRESET_NONE
|
||||
@@ -162,7 +163,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
|
||||
if ATTR_HVAC_MODE in kwargs:
|
||||
data[ATTR_HVAC_MODE] = HA_TO_BSBLAN_HVAC_MODE[kwargs[ATTR_HVAC_MODE]]
|
||||
if ATTR_PRESET_MODE in kwargs:
|
||||
# eco preset uses BSB-LAN mode 2, none preset uses mode 1 (auto)
|
||||
# eco preset uses BSB-Lan mode 2, none preset uses mode 1 (auto)
|
||||
if kwargs[ATTR_PRESET_MODE] == PRESET_ECO:
|
||||
data[ATTR_HVAC_MODE] = 2
|
||||
elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for BSB-LAN integration."""
|
||||
"""Config flow for BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the BSB-LAN integration."""
|
||||
"""Constants for the BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""DataUpdateCoordinator for the BSB-LAN integration."""
|
||||
"""DataUpdateCoordinator for the BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,13 +29,8 @@ if TYPE_CHECKING:
|
||||
|
||||
# Filter lists for optimized API calls - only fetch parameters we actually use
|
||||
# This significantly reduces response time (~0.2s per parameter saved)
|
||||
STATE_INCLUDE = [
|
||||
"current_temperature",
|
||||
"target_temperature",
|
||||
"hvac_mode",
|
||||
"hvac_action",
|
||||
]
|
||||
SENSOR_INCLUDE = ["current_temperature", "outside_temperature", "total_energy"]
|
||||
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
|
||||
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
|
||||
DHW_STATE_INCLUDE = [
|
||||
"operating_mode",
|
||||
"nominal_setpoint",
|
||||
@@ -62,7 +57,7 @@ class BSBLanSlowData:
|
||||
|
||||
|
||||
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
|
||||
"""Base BSB-LAN coordinator."""
|
||||
"""Base BSB-Lan coordinator."""
|
||||
|
||||
config_entry: BSBLanConfigEntry
|
||||
|
||||
@@ -74,7 +69,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
|
||||
name: str,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the BSB-LAN coordinator."""
|
||||
"""Initialize the BSB-Lan coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
@@ -86,7 +81,7 @@ class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
|
||||
|
||||
|
||||
class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
"""The BSB-LAN fast update coordinator for frequently changing data."""
|
||||
"""The BSB-Lan fast update coordinator for frequently changing data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -94,7 +89,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
config_entry: BSBLanConfigEntry,
|
||||
client: BSBLAN,
|
||||
) -> None:
|
||||
"""Initialize the BSB-LAN fast coordinator."""
|
||||
"""Initialize the BSB-Lan fast coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
@@ -104,7 +99,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> BSBLanFastData:
|
||||
"""Fetch fast-changing data from the BSB-LAN device."""
|
||||
"""Fetch fast-changing data from the BSB-Lan device."""
|
||||
try:
|
||||
# Client is already initialized in async_setup_entry
|
||||
# Use include filtering to only fetch parameters we actually use
|
||||
@@ -115,15 +110,12 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
|
||||
except BSBLANAuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="coordinator_auth_error",
|
||||
"Authentication failed for BSB-Lan device"
|
||||
) from err
|
||||
except BSBLANConnectionError as err:
|
||||
host = self.config_entry.data[CONF_HOST]
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="coordinator_connection_error",
|
||||
translation_placeholders={"host": host},
|
||||
f"Error while establishing connection with BSB-Lan device at {host}"
|
||||
) from err
|
||||
|
||||
return BSBLanFastData(
|
||||
@@ -134,7 +126,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
|
||||
|
||||
|
||||
class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
"""The BSB-LAN slow update coordinator for infrequently changing data."""
|
||||
"""The BSB-Lan slow update coordinator for infrequently changing data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -142,7 +134,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
config_entry: BSBLanConfigEntry,
|
||||
client: BSBLAN,
|
||||
) -> None:
|
||||
"""Initialize the BSB-LAN slow coordinator."""
|
||||
"""Initialize the BSB-Lan slow coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
@@ -152,7 +144,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> BSBLanSlowData:
|
||||
"""Fetch slow-changing data from the BSB-LAN device."""
|
||||
"""Fetch slow-changing data from the BSB-Lan device."""
|
||||
try:
|
||||
# Client is already initialized in async_setup_entry
|
||||
# Use include filtering to only fetch parameters we actually use
|
||||
|
||||
@@ -32,15 +32,6 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
|
||||
model=(
|
||||
data.info.device_identification.value
|
||||
if data.info.device_identification
|
||||
and data.info.device_identification.value
|
||||
else None
|
||||
),
|
||||
model_id=(
|
||||
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
|
||||
if data.info.controller_family
|
||||
and data.info.controller_variant
|
||||
and data.info.controller_family.value
|
||||
and data.info.controller_variant.value
|
||||
else None
|
||||
),
|
||||
sw_version=data.device.version,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Helper functions for BSB-Lan integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bsblan import BSBLAN, BSBLANError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_sync_device_time(client: BSBLAN, device_name: str) -> None:
|
||||
"""Synchronize BSB-LAN device time with Home Assistant.
|
||||
|
||||
Only updates if device time differs from Home Assistant time.
|
||||
|
||||
Args:
|
||||
client: The BSB-LAN client instance.
|
||||
device_name: The name of the device (used in error messages).
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If the time sync operation fails.
|
||||
|
||||
"""
|
||||
try:
|
||||
device_time = await client.time()
|
||||
current_time = dt_util.now()
|
||||
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
# Only sync if device time differs from HA time
|
||||
if device_time.time.value != current_time_str:
|
||||
await client.set_time(current_time_str)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="sync_time_failed",
|
||||
translation_placeholders={
|
||||
"device_name": device_name,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
@@ -1,11 +1,4 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"sync_time": {
|
||||
"default": "mdi:timer-sync-outline"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_hot_water_schedule": {
|
||||
"service": "mdi:calendar-clock"
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"domain": "bsblan",
|
||||
"name": "BSB-LAN",
|
||||
"name": "BSB-Lan",
|
||||
"codeowners": ["@liudger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/bsblan",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.0.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration provides a limited number of entities, all of which are useful to users.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration doesn't have any cases where raising an issue is needed.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration has a fixed single device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for BSB-LAN sensors."""
|
||||
"""Support for BSB-Lan sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class BSBLanSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes BSB-LAN sensor entity."""
|
||||
"""Describes BSB-Lan sensor entity."""
|
||||
|
||||
value_fn: Callable[[BSBLanFastData], StateType]
|
||||
exists_fn: Callable[[BSBLanFastData], bool] = lambda data: True
|
||||
@@ -58,19 +58,6 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
|
||||
),
|
||||
exists_fn=lambda data: data.sensor.outside_temperature is not None,
|
||||
),
|
||||
BSBLanSensorEntityDescription(
|
||||
key="total_energy",
|
||||
translation_key="total_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
value_fn=lambda data: (
|
||||
data.sensor.total_energy.value
|
||||
if data.sensor.total_energy is not None
|
||||
else None
|
||||
),
|
||||
exists_fn=lambda data: data.sensor.total_energy is not None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +66,7 @@ async def async_setup_entry(
|
||||
entry: BSBLanConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up BSB-LAN sensor based on a config entry."""
|
||||
"""Set up BSB-Lan sensor based on a config entry."""
|
||||
data = entry.runtime_data
|
||||
|
||||
# Only create sensors for available data points
|
||||
@@ -94,7 +81,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class BSBLanSensor(BSBLanEntity, SensorEntity):
|
||||
"""Defines a BSB-LAN sensor."""
|
||||
"""Defines a BSB-Lan sensor."""
|
||||
|
||||
entity_description: BSBLanSensorEntityDescription
|
||||
|
||||
@@ -103,7 +90,7 @@ class BSBLanSensor(BSBLanEntity, SensorEntity):
|
||||
data: BSBLanData,
|
||||
description: BSBLanSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize BSB-LAN sensor."""
|
||||
"""Initialize BSB-Lan sensor."""
|
||||
super().__init__(data.fast_coordinator, data)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{data.device.MAC}-{description.key}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Support for BSB-LAN services."""
|
||||
"""Support for BSB-Lan services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import async_sync_device_time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import BSBLanConfigEntry
|
||||
@@ -192,7 +192,7 @@ async def set_hot_water_schedule(service_call: ServiceCall) -> None:
|
||||
)
|
||||
|
||||
try:
|
||||
# Call the BSB-LAN API to set the schedule
|
||||
# Call the BSB-Lan API to set the schedule
|
||||
await client.set_hot_water_schedule(dhw_schedule)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -245,7 +245,25 @@ async def async_sync_time(service_call: ServiceCall) -> None:
|
||||
)
|
||||
|
||||
client = entry.runtime_data.client
|
||||
await async_sync_device_time(client, device_entry.name or device_id)
|
||||
|
||||
try:
|
||||
# Get current device time
|
||||
device_time = await client.time()
|
||||
current_time = dt_util.now()
|
||||
current_time_str = current_time.strftime("%d.%m.%Y %H:%M:%S")
|
||||
|
||||
# Only sync if device time differs from HA time
|
||||
if device_time.time.value != current_time_str:
|
||||
await client.set_time(current_time_str)
|
||||
except BSBLANError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="sync_time_failed",
|
||||
translation_placeholders={
|
||||
"device_name": device_entry.name or device_id,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
SYNC_TIME_SCHEMA = vol.Schema(
|
||||
@@ -257,7 +275,7 @@ SYNC_TIME_SCHEMA = vol.Schema(
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the BSB-LAN services."""
|
||||
"""Register the BSB-Lan services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "A BSB-LAN device was discovered at {host}. Please provide credentials if required.",
|
||||
"title": "BSB-LAN device discovered"
|
||||
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
|
||||
"title": "BSB-Lan device discovered"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"password": "[%key:component::bsblan::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::bsblan::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "The BSB-LAN integration needs to re-authenticate with {name}",
|
||||
"description": "The BSB-Lan integration needs to re-authenticate with {name}",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"user": {
|
||||
@@ -48,32 +48,24 @@
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your BSB-LAN device.",
|
||||
"passkey": "The passkey for your BSB-LAN device.",
|
||||
"password": "The password for your BSB-LAN device.",
|
||||
"port": "The port number of your BSB-LAN device.",
|
||||
"username": "The username for your BSB-LAN device."
|
||||
"host": "The hostname or IP address of your BSB-Lan device.",
|
||||
"passkey": "The passkey for your BSB-Lan device.",
|
||||
"password": "The password for your BSB-Lan device.",
|
||||
"port": "The port number of your BSB-Lan device.",
|
||||
"username": "The username for your BSB-Lan device."
|
||||
},
|
||||
"description": "Set up your BSB-LAN device to integrate with Home Assistant.",
|
||||
"title": "Connect to the BSB-LAN device"
|
||||
"description": "Set up your BSB-Lan device to integrate with Home Assistant.",
|
||||
"title": "Connect to the BSB-Lan device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"sync_time": {
|
||||
"name": "Sync time"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"current_temperature": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
},
|
||||
"total_energy": {
|
||||
"name": "Total energy"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -81,12 +73,6 @@
|
||||
"config_entry_not_loaded": {
|
||||
"message": "The device `{device_name}` is not currently loaded or available"
|
||||
},
|
||||
"coordinator_auth_error": {
|
||||
"message": "Authentication failed for BSB-LAN device"
|
||||
},
|
||||
"coordinator_connection_error": {
|
||||
"message": "Error while establishing connection with BSB-LAN device at {host}"
|
||||
},
|
||||
"end_time_before_start_time": {
|
||||
"message": "End time ({end_time}) must be after start time ({start_time})"
|
||||
},
|
||||
@@ -97,11 +83,14 @@
|
||||
"message": "No configuration entry found for device: {device_id}"
|
||||
},
|
||||
"set_data_error": {
|
||||
"message": "An error occurred while sending the data to the BSB-LAN device"
|
||||
"message": "An error occurred while sending the data to the BSB-Lan device"
|
||||
},
|
||||
"set_operation_mode_error": {
|
||||
"message": "An error occurred while setting the operation mode"
|
||||
},
|
||||
"set_preset_mode_error": {
|
||||
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
|
||||
},
|
||||
"set_schedule_failed": {
|
||||
"message": "Failed to set hot water schedule: {error}"
|
||||
},
|
||||
@@ -112,7 +101,7 @@
|
||||
"message": "Authentication failed while retrieving static device data"
|
||||
},
|
||||
"setup_connection_error": {
|
||||
"message": "Failed to retrieve static device data from BSB-LAN device at {host}"
|
||||
"message": "Failed to retrieve static device data from BSB-Lan device at {host}"
|
||||
},
|
||||
"setup_general_error": {
|
||||
"message": "An unknown error occurred while retrieving static device data"
|
||||
@@ -161,7 +150,7 @@
|
||||
"name": "Set hot water schedule"
|
||||
},
|
||||
"sync_time": {
|
||||
"description": "Synchronize Home Assistant time to the BSB-LAN device. Only updates if device time differs from Home Assistant time.",
|
||||
"description": "Synchronize Home Assistant time to the BSB-Lan device. Only updates if device time differs from Home Assistant time.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "The BSB-LAN device to sync time for.",
|
||||
|
||||
@@ -63,7 +63,6 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
"""Defines a BSBLAN water heater entity."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
|
||||
_attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
@@ -74,6 +73,7 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
|
||||
"""Initialize BSBLAN water heater."""
|
||||
super().__init__(data.fast_coordinator, data.slow_coordinator, data)
|
||||
self._attr_unique_id = format_mac(data.device.MAC)
|
||||
self._attr_operation_list = list(HA_TO_BSBLAN_OPERATION_MODE.keys())
|
||||
|
||||
# Set temperature unit
|
||||
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
|
||||
|
||||
@@ -10,11 +10,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Binary sensor platform for Compit integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from compit_inext_api.consts import CompitParameter
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER_NAME
|
||||
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
NO_SENSOR = "no_sensor"
|
||||
ON_STATES = ["on", "yes", "charging", "alert", "exceeded"]
|
||||
|
||||
DESCRIPTIONS: dict[CompitParameter, BinarySensorEntityDescription] = {
|
||||
CompitParameter.AIRING: BinarySensorEntityDescription(
|
||||
key=CompitParameter.AIRING.value,
|
||||
translation_key="airing",
|
||||
device_class=BinarySensorDeviceClass.WINDOW,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.BATTERY_CHARGE_STATUS: BinarySensorEntityDescription(
|
||||
key=CompitParameter.BATTERY_CHARGE_STATUS.value,
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.CO2_ALERT: BinarySensorEntityDescription(
|
||||
key=CompitParameter.CO2_ALERT.value,
|
||||
translation_key="co2_alert",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.CO2_LEVEL: BinarySensorEntityDescription(
|
||||
key=CompitParameter.CO2_LEVEL.value,
|
||||
translation_key="co2_level",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.DUST_ALERT: BinarySensorEntityDescription(
|
||||
key=CompitParameter.DUST_ALERT.value,
|
||||
translation_key="dust_alert",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.PUMP_STATUS: BinarySensorEntityDescription(
|
||||
key=CompitParameter.PUMP_STATUS.value,
|
||||
translation_key="pump_status",
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
CompitParameter.TEMPERATURE_ALERT: BinarySensorEntityDescription(
|
||||
key=CompitParameter.TEMPERATURE_ALERT.value,
|
||||
translation_key="temperature_alert",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CompitDeviceDescription:
|
||||
"""Class to describe a Compit device."""
|
||||
|
||||
name: str
|
||||
parameters: dict[CompitParameter, BinarySensorEntityDescription]
|
||||
|
||||
|
||||
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
|
||||
12: CompitDeviceDescription(
|
||||
name="Nano Color",
|
||||
parameters={
|
||||
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
|
||||
},
|
||||
),
|
||||
78: CompitDeviceDescription(
|
||||
name="SPM - Nano Color 2",
|
||||
parameters={
|
||||
CompitParameter.DUST_ALERT: DESCRIPTIONS[CompitParameter.DUST_ALERT],
|
||||
CompitParameter.TEMPERATURE_ALERT: DESCRIPTIONS[
|
||||
CompitParameter.TEMPERATURE_ALERT
|
||||
],
|
||||
CompitParameter.CO2_ALERT: DESCRIPTIONS[CompitParameter.CO2_ALERT],
|
||||
},
|
||||
),
|
||||
223: CompitDeviceDescription(
|
||||
name="Nano Color 2",
|
||||
parameters={
|
||||
CompitParameter.AIRING: DESCRIPTIONS[CompitParameter.AIRING],
|
||||
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
|
||||
},
|
||||
),
|
||||
225: CompitDeviceDescription(
|
||||
name="SPM - Nano Color",
|
||||
parameters={
|
||||
CompitParameter.CO2_LEVEL: DESCRIPTIONS[CompitParameter.CO2_LEVEL],
|
||||
},
|
||||
),
|
||||
226: CompitDeviceDescription(
|
||||
name="AF-1",
|
||||
parameters={
|
||||
CompitParameter.BATTERY_CHARGE_STATUS: DESCRIPTIONS[
|
||||
CompitParameter.BATTERY_CHARGE_STATUS
|
||||
],
|
||||
CompitParameter.PUMP_STATUS: DESCRIPTIONS[CompitParameter.PUMP_STATUS],
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CompitConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Compit binary sensor entities from a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
async_add_devices(
|
||||
CompitBinarySensor(
|
||||
coordinator,
|
||||
device_id,
|
||||
device_definition.name,
|
||||
code,
|
||||
entity_description,
|
||||
)
|
||||
for device_id, device in coordinator.connector.all_devices.items()
|
||||
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
|
||||
for code, entity_description in device_definition.parameters.items()
|
||||
if coordinator.connector.get_current_value(device_id, code) != NO_SENSOR
|
||||
)
|
||||
|
||||
|
||||
class CompitBinarySensor(
|
||||
CoordinatorEntity[CompitDataUpdateCoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Representation of a Compit binary sensor entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CompitDataUpdateCoordinator,
|
||||
device_id: int,
|
||||
device_name: str,
|
||||
parameter_code: CompitParameter,
|
||||
entity_description: BinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor entity."""
|
||||
super().__init__(coordinator)
|
||||
self.device_id = device_id
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device_id}_{entity_description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(device_id))},
|
||||
name=device_name,
|
||||
manufacturer=MANUFACTURER_NAME,
|
||||
model=device_name,
|
||||
)
|
||||
self.parameter_code = parameter_code
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.connector.get_device(self.device_id) is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
value = self.coordinator.connector.get_current_value(
|
||||
self.device_id, self.parameter_code
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
return value in ON_STATES
|
||||
@@ -1,25 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"airing": {
|
||||
"default": "mdi:window-open-variant"
|
||||
},
|
||||
"co2_alert": {
|
||||
"default": "mdi:alert"
|
||||
},
|
||||
"co2_level": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"dust_alert": {
|
||||
"default": "mdi:alert"
|
||||
},
|
||||
"pump_status": {
|
||||
"default": "mdi:pump"
|
||||
},
|
||||
"temperature_alert": {
|
||||
"default": "mdi:alert"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"boiler_target_temperature": {
|
||||
"default": "mdi:water-boiler"
|
||||
@@ -158,119 +138,6 @@
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"alarm_code": {
|
||||
"default": "mdi:alert-circle",
|
||||
"state": {
|
||||
"no_alarm": "mdi:check-circle"
|
||||
}
|
||||
},
|
||||
"battery_level": {
|
||||
"default": "mdi:battery"
|
||||
},
|
||||
"boiler_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"calculated_heating_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"calculated_target_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"charging_power": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
"circuit_target_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"co2_percent": {
|
||||
"default": "mdi:molecule-co2"
|
||||
},
|
||||
"collector_power": {
|
||||
"default": "mdi:solar-power"
|
||||
},
|
||||
"collector_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"dhw_measured_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"energy_consumption": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_smart_grid_yesterday": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_today": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_total": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"energy_yesterday": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"fuel_level": {
|
||||
"default": "mdi:gauge"
|
||||
},
|
||||
"humidity": {
|
||||
"default": "mdi:water-percent"
|
||||
},
|
||||
"mixer_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"pk1_function": {
|
||||
"default": "mdi:cog",
|
||||
"state": {
|
||||
"cooling": "mdi:snowflake-thermometer",
|
||||
"off": "mdi:cog-off",
|
||||
"summer": "mdi:weather-sunny",
|
||||
"winter": "mdi:snowflake"
|
||||
}
|
||||
},
|
||||
"pm10_level": {
|
||||
"default": "mdi:air-filter",
|
||||
"state": {
|
||||
"exceeded": "mdi:alert",
|
||||
"no_sensor": "mdi:cancel",
|
||||
"normal": "mdi:air-filter",
|
||||
"warning": "mdi:alert-circle-outline"
|
||||
}
|
||||
},
|
||||
"pm25_level": {
|
||||
"default": "mdi:air-filter",
|
||||
"state": {
|
||||
"exceeded": "mdi:alert",
|
||||
"no_sensor": "mdi:cancel",
|
||||
"normal": "mdi:air-filter",
|
||||
"warning": "mdi:alert-circle-outline"
|
||||
}
|
||||
},
|
||||
"return_circuit_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t2": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t3": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"tank_temperature_t4": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"target_heating_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"default": "mdi:alert",
|
||||
"state": {
|
||||
"no_alarm": "mdi:check-circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,26 +33,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"airing": {
|
||||
"name": "Airing"
|
||||
},
|
||||
"co2_alert": {
|
||||
"name": "CO2 alert"
|
||||
},
|
||||
"co2_level": {
|
||||
"name": "CO2 level"
|
||||
},
|
||||
"dust_alert": {
|
||||
"name": "Dust alert"
|
||||
},
|
||||
"pump_status": {
|
||||
"name": "Pump status"
|
||||
},
|
||||
"temperature_alert": {
|
||||
"name": "Temperature alert"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"boiler_target_temperature": {
|
||||
"name": "Boiler target temperature"
|
||||
@@ -203,219 +183,6 @@
|
||||
"winter": "Winter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"actual_buffer_temp": {
|
||||
"name": "Actual buffer temperature"
|
||||
},
|
||||
"actual_dhw_temp": {
|
||||
"name": "Actual DHW temperature"
|
||||
},
|
||||
"actual_hc_temperature_zone": {
|
||||
"name": "Actual heating circuit {zone} temperature"
|
||||
},
|
||||
"actual_upper_source_temp": {
|
||||
"name": "Actual upper source temperature"
|
||||
},
|
||||
"alarm_code": {
|
||||
"name": "Alarm code",
|
||||
"state": {
|
||||
"battery_fault": "Battery fault",
|
||||
"damaged_outdoor_temp": "Damaged outdoor temperature sensor",
|
||||
"damaged_return_temp": "Damaged return temperature sensor",
|
||||
"discharged_battery": "Discharged battery",
|
||||
"internal_af": "Internal fault",
|
||||
"low_battery_level": "Low battery level",
|
||||
"no_alarm": "No alarm",
|
||||
"no_battery": "No battery",
|
||||
"no_power": "No power",
|
||||
"no_pump": "No pump",
|
||||
"pump_fault": "Pump fault"
|
||||
}
|
||||
},
|
||||
"battery_level": {
|
||||
"name": "Battery level"
|
||||
},
|
||||
"boiler_temperature": {
|
||||
"name": "Boiler temperature"
|
||||
},
|
||||
"buffer_return_temperature": {
|
||||
"name": "Buffer return temperature"
|
||||
},
|
||||
"buffer_set_temperature": {
|
||||
"name": "Buffer set temperature"
|
||||
},
|
||||
"calculated_buffer_temp": {
|
||||
"name": "Calculated buffer temperature"
|
||||
},
|
||||
"calculated_dhw_temp": {
|
||||
"name": "Calculated DHW temperature"
|
||||
},
|
||||
"calculated_heating_temperature": {
|
||||
"name": "Calculated heating temperature"
|
||||
},
|
||||
"calculated_target_temperature": {
|
||||
"name": "Calculated target temperature"
|
||||
},
|
||||
"calculated_upper_source_temp": {
|
||||
"name": "Calculated upper source temperature"
|
||||
},
|
||||
"charging_power": {
|
||||
"name": "Charging power"
|
||||
},
|
||||
"circuit_target_temperature": {
|
||||
"name": "Circuit target temperature"
|
||||
},
|
||||
"co2_percent": {
|
||||
"name": "CO2 percent"
|
||||
},
|
||||
"collector_power": {
|
||||
"name": "Collector power"
|
||||
},
|
||||
"collector_temperature": {
|
||||
"name": "Collector temperature"
|
||||
},
|
||||
"dhw_measured_temperature": {
|
||||
"name": "DHW measured temperature"
|
||||
},
|
||||
"dhw_temperature": {
|
||||
"name": "DHW temperature"
|
||||
},
|
||||
"energy_consumption": {
|
||||
"name": "Energy consumption"
|
||||
},
|
||||
"energy_smart_grid_yesterday": {
|
||||
"name": "Energy smart grid yesterday"
|
||||
},
|
||||
"energy_today": {
|
||||
"name": "Energy today"
|
||||
},
|
||||
"energy_total": {
|
||||
"name": "Energy total"
|
||||
},
|
||||
"energy_yesterday": {
|
||||
"name": "Energy yesterday"
|
||||
},
|
||||
"fuel_level": {
|
||||
"name": "Fuel level"
|
||||
},
|
||||
"heating_target_temperature_zone": {
|
||||
"name": "Heating circuit {zone} target temperature"
|
||||
},
|
||||
"lower_source_temperature": {
|
||||
"name": "Lower source temperature"
|
||||
},
|
||||
"mixer_temperature": {
|
||||
"name": "Mixer temperature"
|
||||
},
|
||||
"mixer_temperature_zone": {
|
||||
"name": "Mixer {zone} temperature"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"pk1_function": {
|
||||
"name": "PK1 function",
|
||||
"state": {
|
||||
"cooling": "Cooling",
|
||||
"holiday": "Holiday",
|
||||
"nano_nr_1": "Nano 1",
|
||||
"nano_nr_2": "Nano 2",
|
||||
"nano_nr_3": "Nano 3",
|
||||
"nano_nr_4": "Nano 4",
|
||||
"nano_nr_5": "Nano 5",
|
||||
"off": "Off",
|
||||
"on": "On",
|
||||
"summer": "Summer",
|
||||
"winter": "Winter"
|
||||
}
|
||||
},
|
||||
"pm10_level": {
|
||||
"name": "PM10 level",
|
||||
"state": {
|
||||
"exceeded": "Exceeded",
|
||||
"no_sensor": "No sensor",
|
||||
"normal": "Normal",
|
||||
"warning": "Warning"
|
||||
}
|
||||
},
|
||||
"pm1_level": {
|
||||
"name": "PM1 level"
|
||||
},
|
||||
"pm25_level": {
|
||||
"name": "PM2.5 level",
|
||||
"state": {
|
||||
"exceeded": "Exceeded",
|
||||
"no_sensor": "No sensor",
|
||||
"normal": "Normal",
|
||||
"warning": "Warning"
|
||||
}
|
||||
},
|
||||
"pm4_level": {
|
||||
"name": "PM4 level"
|
||||
},
|
||||
"preset_mode": {
|
||||
"name": "Preset mode"
|
||||
},
|
||||
"protection_temperature": {
|
||||
"name": "Protection temperature"
|
||||
},
|
||||
"pump_status": {
|
||||
"name": "Pump status",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "On"
|
||||
}
|
||||
},
|
||||
"return_circuit_temperature": {
|
||||
"name": "Return circuit temperature"
|
||||
},
|
||||
"set_target_temperature": {
|
||||
"name": "Set target temperature"
|
||||
},
|
||||
"tank_temperature_t2": {
|
||||
"name": "Tank T2 bottom temperature"
|
||||
},
|
||||
"tank_temperature_t3": {
|
||||
"name": "Tank T3 top temperature"
|
||||
},
|
||||
"tank_temperature_t4": {
|
||||
"name": "Tank T4 temperature"
|
||||
},
|
||||
"target_heating_temperature": {
|
||||
"name": "Target heating temperature"
|
||||
},
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"temperature_alert": {
|
||||
"name": "Temperature alert",
|
||||
"state": {
|
||||
"alert": "Alert",
|
||||
"no_alert": "No alert"
|
||||
}
|
||||
},
|
||||
"upper_source_temperature": {
|
||||
"name": "Upper source temperature"
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm",
|
||||
"state": {
|
||||
"ahu_alarm": "AHU alarm",
|
||||
"bot_alarm": "BOT alarm",
|
||||
"damaged_exhaust_sensor": "Damaged exhaust sensor",
|
||||
"damaged_preheater_sensor": "Damaged preheater sensor",
|
||||
"damaged_supply_and_exhaust_sensors": "Damaged supply and exhaust sensors",
|
||||
"damaged_supply_sensor": "Damaged supply sensor",
|
||||
"no_alarm": "No alarm"
|
||||
}
|
||||
},
|
||||
"ventilation_gear": {
|
||||
"name": "Ventilation gear"
|
||||
},
|
||||
"weather_curve": {
|
||||
"name": "Weather curve"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,10 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pyecobee import (
|
||||
ECOBEE_API_KEY,
|
||||
ECOBEE_PASSWORD,
|
||||
ECOBEE_REFRESH_TOKEN,
|
||||
ECOBEE_USERNAME,
|
||||
Ecobee,
|
||||
ExpiredTokenError,
|
||||
)
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
@@ -25,19 +18,10 @@ type EcobeeConfigEntry = ConfigEntry[EcobeeData]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
|
||||
"""Set up ecobee via a config entry."""
|
||||
api_key = entry.data.get(CONF_API_KEY)
|
||||
username = entry.data.get(CONF_USERNAME)
|
||||
password = entry.data.get(CONF_PASSWORD)
|
||||
api_key = entry.data[CONF_API_KEY]
|
||||
refresh_token = entry.data[CONF_REFRESH_TOKEN]
|
||||
|
||||
runtime_data = EcobeeData(
|
||||
hass,
|
||||
entry,
|
||||
api_key=api_key,
|
||||
username=username,
|
||||
password=password,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
|
||||
|
||||
if not await runtime_data.refresh():
|
||||
return False
|
||||
@@ -62,32 +46,14 @@ class EcobeeData:
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
api_key: str | None = None,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
refresh_token: str | None = None,
|
||||
self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str
|
||||
) -> None:
|
||||
"""Initialize the Ecobee data object."""
|
||||
self._hass = hass
|
||||
self.entry = entry
|
||||
|
||||
if api_key:
|
||||
self.ecobee = Ecobee(
|
||||
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
|
||||
)
|
||||
elif username and password:
|
||||
self.ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
ECOBEE_REFRESH_TOKEN: refresh_token,
|
||||
}
|
||||
)
|
||||
else:
|
||||
raise ValueError("No ecobee credentials provided")
|
||||
self.ecobee = Ecobee(
|
||||
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
|
||||
)
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
async def update(self):
|
||||
@@ -103,23 +69,12 @@ class EcobeeData:
|
||||
"""Refresh ecobee tokens and update config entry."""
|
||||
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
|
||||
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
|
||||
data = {}
|
||||
if self.ecobee.config.get(ECOBEE_API_KEY):
|
||||
data = {
|
||||
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
}
|
||||
elif self.ecobee.config.get(ECOBEE_USERNAME) and self.ecobee.config.get(
|
||||
ECOBEE_PASSWORD
|
||||
):
|
||||
data = {
|
||||
CONF_USERNAME: self.ecobee.config[ECOBEE_USERNAME],
|
||||
CONF_PASSWORD: self.ecobee.config[ECOBEE_PASSWORD],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
}
|
||||
self._hass.config_entries.async_update_entry(
|
||||
self.entry,
|
||||
data=data,
|
||||
data={
|
||||
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
|
||||
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
|
||||
},
|
||||
)
|
||||
return True
|
||||
_LOGGER.error("Error refreshing ecobee tokens")
|
||||
|
||||
@@ -2,21 +2,15 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
|
||||
from pyecobee import ECOBEE_API_KEY, Ecobee
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
|
||||
from .const import CONF_REFRESH_TOKEN, DOMAIN
|
||||
|
||||
_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): str,
|
||||
vol.Optional(CONF_USERNAME): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
|
||||
|
||||
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -33,34 +27,13 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]})
|
||||
|
||||
if api_key and not (username or password):
|
||||
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
|
||||
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
elif username and password and not api_key:
|
||||
self._ecobee = Ecobee(
|
||||
config={
|
||||
ECOBEE_USERNAME: username,
|
||||
ECOBEE_PASSWORD: password,
|
||||
}
|
||||
)
|
||||
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
|
||||
config = {
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
|
||||
}
|
||||
return self.async_create_entry(title=DOMAIN, data=config)
|
||||
errors["base"] = "login_failed"
|
||||
else:
|
||||
errors["base"] = "invalid_auth"
|
||||
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
|
||||
# We have a PIN; move to the next step of the flow.
|
||||
return await self.async_step_authorize()
|
||||
errors["base"] = "pin_request_failed"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
|
||||
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
|
||||
"token_request_failed": "Error requesting tokens from ecobee; please try again."
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WATER_HEATER,
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from pyeconet.equipment import EquipmentType
|
||||
from pyeconet.equipment.thermostat import (
|
||||
Thermostat,
|
||||
ThermostatFanSpeed,
|
||||
ThermostatFanMode,
|
||||
ThermostatOperationMode,
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ from homeassistant.components.climate import (
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_TOP,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
@@ -42,16 +41,13 @@ HA_STATE_TO_ECONET = {
|
||||
if key != ThermostatOperationMode.EMERGENCY_HEAT
|
||||
}
|
||||
|
||||
ECONET_FAN_SPEED_TO_HA = {
|
||||
ThermostatFanSpeed.AUTO: FAN_AUTO,
|
||||
ThermostatFanSpeed.LOW: FAN_LOW,
|
||||
ThermostatFanSpeed.MEDIUM: FAN_MEDIUM,
|
||||
ThermostatFanSpeed.HIGH: FAN_HIGH,
|
||||
ThermostatFanSpeed.MAX: FAN_TOP,
|
||||
}
|
||||
HA_FAN_STATE_TO_ECONET_FAN_SPEED = {
|
||||
value: key for key, value in ECONET_FAN_SPEED_TO_HA.items()
|
||||
ECONET_FAN_STATE_TO_HA = {
|
||||
ThermostatFanMode.AUTO: FAN_AUTO,
|
||||
ThermostatFanMode.LOW: FAN_LOW,
|
||||
ThermostatFanMode.MEDIUM: FAN_MEDIUM,
|
||||
ThermostatFanMode.HIGH: FAN_HIGH,
|
||||
}
|
||||
HA_FAN_STATE_TO_ECONET = {value: key for key, value in ECONET_FAN_STATE_TO_HA.items()}
|
||||
|
||||
SUPPORT_FLAGS_THERMOSTAT = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
@@ -107,7 +103,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
return self._econet.set_point
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> int | None:
|
||||
def current_humidity(self) -> int:
|
||||
"""Return the current humidity."""
|
||||
return self._econet.humidity
|
||||
|
||||
@@ -153,7 +149,7 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation i.e. heat, cool, mode.
|
||||
"""Return hvac operation ie. heat, cool, mode.
|
||||
|
||||
Needs to be one of HVAC_MODE_*.
|
||||
"""
|
||||
@@ -178,35 +174,35 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
|
||||
@property
|
||||
def fan_mode(self) -> str:
|
||||
"""Return the current fan mode."""
|
||||
econet_fan_speed = self._econet.fan_speed
|
||||
econet_fan_mode = self._econet.fan_mode
|
||||
|
||||
# Remove this after we figure out how to handle med lo and med hi
|
||||
if econet_fan_speed in [ThermostatFanSpeed.MEDHI, ThermostatFanSpeed.MEDLO]:
|
||||
econet_fan_speed = ThermostatFanSpeed.MEDIUM
|
||||
if econet_fan_mode in [ThermostatFanMode.MEDHI, ThermostatFanMode.MEDLO]:
|
||||
econet_fan_mode = ThermostatFanMode.MEDIUM
|
||||
|
||||
_current_fan_speed = FAN_AUTO
|
||||
if econet_fan_speed is not None:
|
||||
_current_fan_speed = ECONET_FAN_SPEED_TO_HA[econet_fan_speed]
|
||||
return _current_fan_speed
|
||||
_current_fan_mode = FAN_AUTO
|
||||
if econet_fan_mode is not None:
|
||||
_current_fan_mode = ECONET_FAN_STATE_TO_HA[econet_fan_mode]
|
||||
return _current_fan_mode
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
"""Return the fan modes."""
|
||||
# Remove the MEDLO MEDHI once we figure out how to handle it
|
||||
return [
|
||||
ECONET_FAN_SPEED_TO_HA[mode]
|
||||
for mode in self._econet.fan_speeds
|
||||
ECONET_FAN_STATE_TO_HA[mode]
|
||||
for mode in self._econet.fan_modes
|
||||
# Remove the MEDLO MEDHI once we figure out how to handle it
|
||||
if mode
|
||||
not in [
|
||||
ThermostatFanSpeed.UNKNOWN,
|
||||
ThermostatFanSpeed.MEDLO,
|
||||
ThermostatFanSpeed.MEDHI,
|
||||
ThermostatFanMode.UNKNOWN,
|
||||
ThermostatFanMode.MEDLO,
|
||||
ThermostatFanMode.MEDHI,
|
||||
]
|
||||
]
|
||||
|
||||
def set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
self._econet.set_fan_speed(HA_FAN_STATE_TO_ECONET_FAN_SPEED[fan_mode])
|
||||
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["paho_mqtt", "pyeconet"],
|
||||
"requirements": ["pyeconet==0.2.1"]
|
||||
"requirements": ["pyeconet==0.1.28"]
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Support for Rheem EcoNet thermostats with variable fan speeds and fan modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyeconet.equipment import EquipmentType
|
||||
from pyeconet.equipment.thermostat import Thermostat, ThermostatFanMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EconetConfigEntry
|
||||
from .entity import EcoNetEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EconetConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the econet thermostat select entity."""
|
||||
equipment = entry.runtime_data
|
||||
async_add_entities(
|
||||
EconetFanModeSelect(thermostat)
|
||||
for thermostat in equipment[EquipmentType.THERMOSTAT]
|
||||
if thermostat.supports_fan_mode
|
||||
)
|
||||
|
||||
|
||||
class EconetFanModeSelect(EcoNetEntity[Thermostat], SelectEntity):
|
||||
"""Select entity."""
|
||||
|
||||
def __init__(self, thermostat: Thermostat) -> None:
|
||||
"""Initialize EcoNet platform."""
|
||||
super().__init__(thermostat)
|
||||
self._attr_name = f"{thermostat.device_name} fan mode"
|
||||
self._attr_unique_id = (
|
||||
f"{thermostat.device_id}_{thermostat.device_name}_fan_mode"
|
||||
)
|
||||
|
||||
@property
|
||||
def options(self) -> list[str]:
|
||||
"""Return available select options."""
|
||||
return [e.value for e in self._econet.fan_modes]
|
||||
|
||||
@property
|
||||
def current_option(self) -> str:
|
||||
"""Return current select option."""
|
||||
return self._econet.fan_mode.value
|
||||
|
||||
def select_option(self, option: str) -> None:
|
||||
"""Set the selected option."""
|
||||
self._econet.set_fan_mode(ThermostatFanMode.by_string(option))
|
||||
@@ -23,20 +23,19 @@ async def async_setup_entry(
|
||||
entry: EconetConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the econet thermostat switch entity."""
|
||||
"""Set up the ecobee thermostat switch entity."""
|
||||
equipment = entry.runtime_data
|
||||
async_add_entities(
|
||||
EcoNetSwitchAuxHeatOnly(thermostat)
|
||||
for thermostat in equipment[EquipmentType.THERMOSTAT]
|
||||
if ThermostatOperationMode.EMERGENCY_HEAT in thermostat.modes
|
||||
)
|
||||
|
||||
|
||||
class EcoNetSwitchAuxHeatOnly(EcoNetEntity[Thermostat], SwitchEntity):
|
||||
"""Representation of an aux_heat_only EcoNet switch."""
|
||||
"""Representation of a aux_heat_only EcoNet switch."""
|
||||
|
||||
def __init__(self, thermostat: Thermostat) -> None:
|
||||
"""Initialize EcoNet platform."""
|
||||
"""Initialize EcoNet ventilator platform."""
|
||||
super().__init__(thermostat)
|
||||
self._attr_name = f"{thermostat.device_name} emergency heat"
|
||||
self._attr_unique_id = (
|
||||
|
||||
@@ -4,23 +4,17 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.usb import (
|
||||
human_readable_device_name,
|
||||
usb_unique_id_from_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
|
||||
from . import dongle
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -37,48 +31,8 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the EnOcean config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
|
||||
"""Handle usb discovery."""
|
||||
unique_id = usb_unique_id_from_service_info(discovery_info)
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_DEVICE: discovery_info.device}
|
||||
)
|
||||
|
||||
discovery_info.device = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, discovery_info.device
|
||||
)
|
||||
|
||||
self.data[CONF_DEVICE] = discovery_info.device
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: human_readable_device_name(
|
||||
discovery_info.device,
|
||||
discovery_info.serial_number,
|
||||
discovery_info.manufacturer,
|
||||
discovery_info.description,
|
||||
discovery_info.vid,
|
||||
discovery_info.pid,
|
||||
)
|
||||
}
|
||||
return await self.async_step_usb_confirm()
|
||||
|
||||
async def async_step_usb_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle USB Discovery confirmation."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]})
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="usb_confirm",
|
||||
description_placeholders={
|
||||
ATTR_MANUFACTURER: MANUFACTURER,
|
||||
CONF_DEVICE: self.data.get(CONF_DEVICE, ""),
|
||||
},
|
||||
)
|
||||
self.dongle_path = None
|
||||
self.discovery_info = None
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a yaml configuration."""
|
||||
@@ -150,4 +104,4 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def create_enocean_entry(self, user_input):
|
||||
"""Create an entry for the provided configuration."""
|
||||
return self.async_create_entry(title=MANUFACTURER, data=user_input)
|
||||
return self.async_create_entry(title="EnOcean", data=user_input)
|
||||
|
||||
@@ -6,8 +6,6 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "enocean"
|
||||
|
||||
MANUFACTURER = "EnOcean"
|
||||
|
||||
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"
|
||||
|
||||
SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message"
|
||||
|
||||
@@ -3,19 +3,10 @@
|
||||
"name": "EnOcean",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["enocean"],
|
||||
"requirements": ["enocean==0.50"],
|
||||
"single_config_entry": true,
|
||||
"usb": [
|
||||
{
|
||||
"description": "*usb 300*",
|
||||
"manufacturer": "*enocean*",
|
||||
"pid": "6001",
|
||||
"vid": "0403"
|
||||
}
|
||||
]
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
"device": "[%key:component::enocean::config::step::detect::data_description::device%]"
|
||||
},
|
||||
"description": "Enter the path to your EnOcean USB dongle."
|
||||
},
|
||||
"usb_confirm": {
|
||||
"description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -300,23 +300,16 @@ class RuntimeEntryData:
|
||||
needed_platforms.add(Platform.BINARY_SENSOR)
|
||||
needed_platforms.add(Platform.SELECT)
|
||||
|
||||
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
# Make a dict of the EntityInfo by type and send
|
||||
# them to the listeners for each specific EntityInfo type
|
||||
info_types_to_platform = INFO_TYPE_TO_PLATFORM
|
||||
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
|
||||
list
|
||||
)
|
||||
for info in infos:
|
||||
info_type = type(info)
|
||||
if platform := info_types_to_platform.get(info_type):
|
||||
needed_platforms.add(platform)
|
||||
infos_by_type[info_type].append(info)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Entity type %s is not supported in this version of Home Assistant",
|
||||
info_type,
|
||||
)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
infos_by_type[type(info)].append(info)
|
||||
|
||||
for type_, callbacks in self.entity_info_callbacks.items():
|
||||
# If all entities for a type are removed, we
|
||||
|
||||
@@ -12,7 +12,11 @@ import re
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from fritzconnection import FritzConnection
|
||||
from fritzconnection.core.exceptions import FritzActionError
|
||||
from fritzconnection.core.exceptions import (
|
||||
FritzActionError,
|
||||
FritzConnectionException,
|
||||
FritzSecurityError,
|
||||
)
|
||||
from fritzconnection.lib.fritzcall import FritzCall
|
||||
from fritzconnection.lib.fritzhosts import FritzHosts
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
@@ -43,7 +47,6 @@ from .const import (
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_USERNAME,
|
||||
DOMAIN,
|
||||
FRITZ_AUTH_EXCEPTIONS,
|
||||
FRITZ_EXCEPTIONS,
|
||||
SCAN_INTERVAL,
|
||||
MeshRoles,
|
||||
@@ -422,18 +425,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
hosts_info: list[HostInfo] = []
|
||||
try:
|
||||
try:
|
||||
hosts_attributes = cast(
|
||||
list[HostAttributes],
|
||||
await self.hass.async_add_executor_job(
|
||||
self.fritz_hosts.get_hosts_attributes
|
||||
),
|
||||
hosts_attributes = await self.hass.async_add_executor_job(
|
||||
self.fritz_hosts.get_hosts_attributes
|
||||
)
|
||||
except FritzActionError:
|
||||
hosts_info = cast(
|
||||
list[HostInfo],
|
||||
await self.hass.async_add_executor_job(
|
||||
self.fritz_hosts.get_hosts_info
|
||||
),
|
||||
hosts_info = await self.hass.async_add_executor_job(
|
||||
self.fritz_hosts.get_hosts_info
|
||||
)
|
||||
except Exception as ex:
|
||||
if not self.hass.is_stopping:
|
||||
@@ -589,7 +586,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
topology := await self.hass.async_add_executor_job(
|
||||
self.fritz_hosts.get_mesh_topology
|
||||
)
|
||||
) or not isinstance(topology, dict):
|
||||
):
|
||||
raise Exception("Mesh supported but empty topology reported") # noqa: TRY002
|
||||
except FritzActionError:
|
||||
self.mesh_role = MeshRoles.SLAVE
|
||||
@@ -745,7 +742,7 @@ class AvmWrapper(FritzBoxTools):
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
except FRITZ_AUTH_EXCEPTIONS:
|
||||
except FritzSecurityError:
|
||||
_LOGGER.exception(
|
||||
"Authorization Error: Please check the provided credentials and"
|
||||
" verify that you can log into the web interface"
|
||||
@@ -758,6 +755,12 @@ class AvmWrapper(FritzBoxTools):
|
||||
action_name,
|
||||
)
|
||||
return {}
|
||||
except FritzConnectionException:
|
||||
_LOGGER.exception(
|
||||
"Connection Error: Please check the device is properly configured"
|
||||
" for remote login"
|
||||
)
|
||||
return {}
|
||||
return result
|
||||
|
||||
async def async_get_upnp_configuration(self) -> dict[str, Any]:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "fritzbox_callmonitor",
|
||||
"name": "FRITZ!Box Call Monitor",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@cdce8p"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
|
||||
"""Initialize a trait for a state."""
|
||||
super().__init__(hass, state, config)
|
||||
if state.domain == fan.DOMAIN:
|
||||
speed_count = round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
speed_count = min(
|
||||
FAN_SPEED_MAX_SPEED_COUNT,
|
||||
round(
|
||||
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
|
||||
),
|
||||
)
|
||||
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
else:
|
||||
self._ordered_speed = []
|
||||
self._ordered_speed = [
|
||||
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
@@ -1786,11 +1786,7 @@ class FanSpeedTrait(_Trait):
|
||||
result.update(
|
||||
{
|
||||
"reversible": reversible,
|
||||
# supportsFanSpeedPercent is mutually exclusive with
|
||||
# availableFanSpeeds, where supportsFanSpeedPercent takes
|
||||
# precedence. Report it only when step speeds are not
|
||||
# supported so Google renders a percent slider (1-100%).
|
||||
"supportsFanSpeedPercent": not self._ordered_speed,
|
||||
"supportsFanSpeedPercent": True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1836,12 +1832,10 @@ class FanSpeedTrait(_Trait):
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
|
||||
if self._ordered_speed:
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
else:
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
response["currentFanSpeedPercent"] = percent
|
||||
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
|
||||
self._ordered_speed, percent
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -1861,7 +1855,7 @@ class FanSpeedTrait(_Trait):
|
||||
)
|
||||
|
||||
if domain == fan.DOMAIN:
|
||||
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
|
||||
if fan_speed := params.get("fanSpeed"):
|
||||
fan_speed_percent = ordered_list_item_to_percentage(
|
||||
self._ordered_speed, fan_speed
|
||||
)
|
||||
|
||||
@@ -181,7 +181,8 @@ class HassIOIngress(HomeAssistantView):
|
||||
skip_auto_headers={hdrs.CONTENT_TYPE},
|
||||
) as result:
|
||||
headers = _response_header(result)
|
||||
|
||||
content_length_int = 0
|
||||
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
|
||||
# Avoid parsing content_type in simple cases for better performance
|
||||
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
|
||||
content_type: str = (maybe_content_type.partition(";"))[0].strip()
|
||||
@@ -189,30 +190,17 @@ class HassIOIngress(HomeAssistantView):
|
||||
# default value according to RFC 2616
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Empty body responses (304, 204, HEAD, etc.) should not be streamed,
|
||||
# otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk
|
||||
# This also avoids setting content_type for empty responses.
|
||||
if must_be_empty_body(request.method, result.status):
|
||||
# If upstream contains content-type, preserve it (e.g. for HEAD requests)
|
||||
# Note: This still is omitting content-length. We can't simply forward
|
||||
# the upstream length since the proxy might change the body length
|
||||
# (e.g. due to compression).
|
||||
if maybe_content_type:
|
||||
headers[hdrs.CONTENT_TYPE] = content_type
|
||||
return web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
)
|
||||
|
||||
# Simple request
|
||||
content_length_int = 0
|
||||
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
|
||||
if (
|
||||
if (empty_body := must_be_empty_body(result.method, result.status)) or (
|
||||
content_length is not UNDEFINED
|
||||
and (content_length_int := int(content_length))
|
||||
<= MAX_SIMPLE_RESPONSE_SIZE
|
||||
):
|
||||
body = await result.read()
|
||||
# Return Response
|
||||
if empty_body:
|
||||
body = None
|
||||
else:
|
||||
body = await result.read()
|
||||
simple_response = web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -71,11 +70,6 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
authtoken = await self.auth.async_register()
|
||||
if authtoken:
|
||||
_LOGGER.debug("Write config entry for HomematicIP Cloud")
|
||||
if self.source == "reauth":
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={HMIPC_AUTHTOKEN: authtoken},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self.auth.config[HMIPC_HAPID],
|
||||
data={
|
||||
@@ -84,50 +78,11 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
HMIPC_NAME: self.auth.config.get(HMIPC_NAME),
|
||||
},
|
||||
)
|
||||
if self.source == "reauth":
|
||||
errors["base"] = "connection_aborted"
|
||||
else:
|
||||
return self.async_abort(reason="connection_aborted")
|
||||
else:
|
||||
errors["base"] = "press_the_button"
|
||||
return self.async_abort(reason="connection_aborted")
|
||||
errors["base"] = "press_the_button"
|
||||
|
||||
return self.async_show_form(step_id="link", errors=errors)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauthentication when the auth token becomes invalid."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth confirmation and start link process."""
|
||||
errors = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
config = {
|
||||
HMIPC_HAPID: reauth_entry.data[HMIPC_HAPID],
|
||||
HMIPC_PIN: user_input.get(HMIPC_PIN),
|
||||
HMIPC_NAME: reauth_entry.data.get(HMIPC_NAME),
|
||||
}
|
||||
self.auth = HomematicipAuth(self.hass, config)
|
||||
connected = await self.auth.async_setup()
|
||||
if connected:
|
||||
return await self.async_step_link()
|
||||
errors["base"] = "invalid_sgtin_or_pin"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(HMIPC_PIN): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResult:
|
||||
"""Import a new access point as a config entry."""
|
||||
hapid = import_data[HMIPC_HAPID].replace("-", "").upper()
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Diagnostics support for HomematicIP Cloud."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.helpers import handle_config
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .hap import HomematicIPConfigEntry
|
||||
|
||||
TO_REDACT_CONFIG = {"city", "latitude", "longitude", "refreshToken"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: HomematicIPConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
json_state = await hap.home.download_configuration_async()
|
||||
anonymized = handle_config(json_state, anonymize=True)
|
||||
config = json.loads(anonymized)
|
||||
|
||||
return async_redact_data(config, TO_REDACT_CONFIG)
|
||||
@@ -12,10 +12,7 @@ from homematicip.auth import Auth
|
||||
from homematicip.base.enums import EventType
|
||||
from homematicip.connection.connection_context import ConnectionContextBuilder
|
||||
from homematicip.connection.rest_connection import RestConnection
|
||||
from homematicip.exceptions.connection_exceptions import (
|
||||
HmipAuthenticationError,
|
||||
HmipConnectionError,
|
||||
)
|
||||
from homematicip.exceptions.connection_exceptions import HmipConnectionError
|
||||
|
||||
import homeassistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -195,12 +192,6 @@ class HomematicipHAP:
|
||||
try:
|
||||
await self.get_state()
|
||||
break
|
||||
except HmipAuthenticationError:
|
||||
_LOGGER.error(
|
||||
"Authentication error from HomematicIP Cloud, triggering reauth"
|
||||
)
|
||||
self.config_entry.async_start_reauth(self.hass)
|
||||
break
|
||||
except HmipConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Get_state failed, retrying in %s seconds: %s", delay, err
|
||||
|
||||
@@ -55,7 +55,7 @@ async def async_setup_entry(
|
||||
entities: list[HomematicipGenericEntity] = []
|
||||
|
||||
entities.extend(
|
||||
HomematicipColorLight(hap, d, ch.index)
|
||||
HomematicipLightHS(hap, d, ch.index)
|
||||
for d in hap.home.devices
|
||||
for ch in d.functionalChannels
|
||||
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
|
||||
@@ -136,32 +136,16 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
|
||||
await self._device.turn_off_async()
|
||||
|
||||
|
||||
class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of the HomematicIP color light."""
|
||||
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
|
||||
"""Representation of the HomematicIP light with HS color mode."""
|
||||
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
|
||||
"""Initialize the light entity."""
|
||||
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
|
||||
|
||||
def _supports_color(self) -> bool:
|
||||
"""Return true if device supports hue/saturation color control."""
|
||||
channel = self.get_channel_or_raise()
|
||||
return channel.hue is not None and channel.saturationLevel is not None
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._supports_color():
|
||||
return ColorMode.HS
|
||||
return ColorMode.BRIGHTNESS
|
||||
|
||||
@property
|
||||
def supported_color_modes(self) -> set[ColorMode]:
|
||||
"""Return the supported color modes."""
|
||||
if self._supports_color():
|
||||
return {ColorMode.HS}
|
||||
return {ColorMode.BRIGHTNESS}
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if light is on."""
|
||||
@@ -188,26 +172,18 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on."""
|
||||
channel = self.get_channel_or_raise()
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
|
||||
hue = hs_color[0] % 360.0
|
||||
saturation = hs_color[1] / 100.0
|
||||
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
|
||||
|
||||
if ATTR_HS_COLOR not in kwargs:
|
||||
hue = channel.hue
|
||||
saturation = channel.saturationLevel
|
||||
|
||||
if ATTR_BRIGHTNESS not in kwargs:
|
||||
# If no brightness is set, use the current brightness
|
||||
dim_level = channel.dimLevel or 1.0
|
||||
|
||||
# Use dim-only method for monochrome mode (hue/saturation not supported)
|
||||
if not self._supports_color():
|
||||
await channel.set_dim_level_async(dim_level=dim_level)
|
||||
return
|
||||
|
||||
# Full color mode with hue/saturation
|
||||
if ATTR_HS_COLOR in kwargs:
|
||||
hs_color = kwargs[ATTR_HS_COLOR]
|
||||
hue = hs_color[0] % 360.0
|
||||
saturation = hs_color[1] / 100.0
|
||||
else:
|
||||
hue = channel.hue
|
||||
saturation = channel.saturationLevel
|
||||
|
||||
await channel.set_hue_saturation_dim_level_async(
|
||||
hue=hue, saturation_level=saturation, dim_level=dim_level
|
||||
)
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"connection_aborted": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"error": {
|
||||
"connection_aborted": "Registration failed, please try again.",
|
||||
"invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.",
|
||||
"press_the_button": "Please press the blue button.",
|
||||
"register_failed": "Failed to register, please try again.",
|
||||
@@ -26,13 +24,6 @@
|
||||
"link": {
|
||||
"description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n",
|
||||
"title": "Link access point"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
},
|
||||
"description": "The authentication token for your HomematicIP access point is no longer valid. Press **Submit** and then press the blue button on your access point to re-register.",
|
||||
"title": "Re-authenticate HomematicIP access point"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ from functools import partial
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
from tempfile import NamedTemporaryFile
|
||||
@@ -69,7 +70,7 @@ from .headers import setup_headers
|
||||
from .request_context import setup_request_context
|
||||
from .security_filter import setup_security_filter
|
||||
from .static import CACHE_HEADERS, CachingStaticResource
|
||||
from .web_runner import HomeAssistantTCPSite
|
||||
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
|
||||
|
||||
CONF_SERVER_HOST: Final = "server_host"
|
||||
CONF_SERVER_PORT: Final = "server_port"
|
||||
@@ -235,6 +236,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
source_ip_task = create_eager_task(async_get_source_ip(hass))
|
||||
|
||||
unix_socket_path: Path | None = None
|
||||
if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
|
||||
socket_path = Path(socket_env)
|
||||
if socket_path.is_absolute():
|
||||
unix_socket_path = socket_path
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Invalid unix socket path %s: path must be absolute", socket_env
|
||||
)
|
||||
|
||||
server = HomeAssistantHTTP(
|
||||
hass,
|
||||
server_host=server_host,
|
||||
@@ -244,6 +255,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ssl_key=ssl_key,
|
||||
trusted_proxies=trusted_proxies,
|
||||
ssl_profile=ssl_profile,
|
||||
unix_socket_path=unix_socket_path,
|
||||
)
|
||||
await server.async_initialize(
|
||||
cors_origins=cors_origins,
|
||||
@@ -366,6 +378,7 @@ class HomeAssistantHTTP:
|
||||
server_port: int,
|
||||
trusted_proxies: list[IPv4Network | IPv6Network],
|
||||
ssl_profile: str,
|
||||
unix_socket_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Initialize the HTTP Home Assistant server."""
|
||||
self.app = HomeAssistantApplication(
|
||||
@@ -384,8 +397,10 @@ class HomeAssistantHTTP:
|
||||
self.server_port = server_port
|
||||
self.trusted_proxies = trusted_proxies
|
||||
self.ssl_profile = ssl_profile
|
||||
self.unix_socket_path = unix_socket_path
|
||||
self.runner: web.AppRunner | None = None
|
||||
self.site: HomeAssistantTCPSite | None = None
|
||||
self.unix_site: HomeAssistantUnixSite | None = None
|
||||
self.context: ssl.SSLContext | None = None
|
||||
|
||||
async def async_initialize(
|
||||
@@ -623,6 +638,20 @@ class HomeAssistantHTTP:
|
||||
)
|
||||
await self.runner.setup()
|
||||
|
||||
if self.unix_socket_path is not None:
|
||||
self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path)
|
||||
try:
|
||||
await self.unix_site.start()
|
||||
except OSError as error:
|
||||
_LOGGER.error(
|
||||
"Failed to create HTTP server on unix socket %s: %s",
|
||||
self.unix_socket_path,
|
||||
error,
|
||||
)
|
||||
self.unix_site = None
|
||||
else:
|
||||
_LOGGER.info("Now listening on unix socket %s", self.unix_socket_path)
|
||||
|
||||
self.site = HomeAssistantTCPSite(
|
||||
self.runner, self.server_host, self.server_port, ssl_context=self.context
|
||||
)
|
||||
@@ -637,6 +666,10 @@ class HomeAssistantHTTP:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the aiohttp server."""
|
||||
if self.unix_site is not None:
|
||||
await self.unix_site.stop()
|
||||
if self.unix_socket_path is not None:
|
||||
self.unix_socket_path.unlink(missing_ok=True)
|
||||
if self.site is not None:
|
||||
await self.site.stop()
|
||||
if self.runner is not None:
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.auth import jwt_wrapper
|
||||
from homeassistant.auth.const import GROUP_ID_READ_ONLY
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.const import HASSIO_USER_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.http import current_request
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
@@ -27,7 +28,12 @@ from homeassistant.helpers.network import is_cloud_connection
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.network import is_local
|
||||
|
||||
from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER
|
||||
from .const import (
|
||||
KEY_AUTHENTICATED,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
KEY_HASS_USER,
|
||||
is_unix_socket_request,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -117,7 +123,7 @@ def async_user_not_allowed_do_auth(
|
||||
return "User cannot authenticate remotely"
|
||||
|
||||
|
||||
async def async_setup_auth(
|
||||
async def async_setup_auth( # noqa: C901
|
||||
hass: HomeAssistant,
|
||||
app: Application,
|
||||
) -> None:
|
||||
@@ -207,6 +213,27 @@ async def async_setup_auth(
|
||||
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
|
||||
return True
|
||||
|
||||
supervisor_user_id: str | None = None
|
||||
|
||||
async def async_authenticate_unix_socket(request: Request) -> bool:
|
||||
"""Authenticate a request from a Unix socket as the Supervisor user."""
|
||||
nonlocal supervisor_user_id
|
||||
|
||||
# Fast path: use cached user ID
|
||||
if supervisor_user_id is not None:
|
||||
if user := await hass.auth.async_get_user(supervisor_user_id):
|
||||
request[KEY_HASS_USER] = user
|
||||
return True
|
||||
supervisor_user_id = None
|
||||
|
||||
# Slow path: find the Supervisor user by name
|
||||
for user in await hass.auth.async_get_users():
|
||||
if user.system_generated and user.name == HASSIO_USER_NAME:
|
||||
supervisor_user_id = user.id
|
||||
request[KEY_HASS_USER] = user
|
||||
return True
|
||||
return False
|
||||
|
||||
@middleware
|
||||
async def auth_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
@@ -214,7 +241,11 @@ async def async_setup_auth(
|
||||
"""Authenticate as middleware."""
|
||||
authenticated = False
|
||||
|
||||
if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
|
||||
if is_unix_socket_request(request):
|
||||
authenticated = await async_authenticate_unix_socket(request)
|
||||
auth_type = "unix socket"
|
||||
|
||||
elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header(
|
||||
request
|
||||
):
|
||||
authenticated = True
|
||||
@@ -233,7 +264,7 @@ async def async_setup_auth(
|
||||
if authenticated and _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug(
|
||||
"Authenticated %s for %s using %s",
|
||||
request.remote,
|
||||
request.remote or "unknown",
|
||||
request.path,
|
||||
auth_type,
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio
|
||||
from homeassistant.util import dt as dt_util, yaml as yaml_util
|
||||
|
||||
from .const import KEY_HASS
|
||||
from .const import KEY_HASS, is_unix_socket_request
|
||||
from .view import HomeAssistantView
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
@@ -72,6 +72,10 @@ async def ban_middleware(
|
||||
request: Request, handler: Callable[[Request], Awaitable[StreamResponse]]
|
||||
) -> StreamResponse:
|
||||
"""IP Ban middleware."""
|
||||
# Unix socket connections are trusted, skip ban checks
|
||||
if is_unix_socket_request(request):
|
||||
return await handler(request)
|
||||
|
||||
if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None:
|
||||
_LOGGER.error("IP Ban middleware loaded but banned IPs not loaded")
|
||||
return await handler(request)
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""HTTP specific constants."""
|
||||
|
||||
import socket
|
||||
from typing import Final
|
||||
|
||||
from aiohttp.web import Request
|
||||
|
||||
from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401
|
||||
|
||||
DOMAIN: Final = "http"
|
||||
|
||||
KEY_HASS_USER: Final = "hass_user"
|
||||
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
|
||||
|
||||
|
||||
def is_unix_socket_request(request: Request) -> bool:
|
||||
"""Check if request arrived over a Unix socket."""
|
||||
if (transport := request.transport) is None:
|
||||
return False
|
||||
if (sock := transport.get_extra_info("socket")) is None:
|
||||
return False
|
||||
return bool(sock.family == socket.AF_UNIX)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from ssl import SSLContext
|
||||
|
||||
from aiohttp import web
|
||||
@@ -68,3 +69,49 @@ class HomeAssistantTCPSite(web.BaseSite):
|
||||
reuse_address=self._reuse_address,
|
||||
reuse_port=self._reuse_port,
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantUnixSite(web.BaseSite):
|
||||
"""HomeAssistant specific aiohttp UnixSite.
|
||||
|
||||
Listens on a Unix socket for local inter-process communication,
|
||||
used for Supervisor to Core communication.
|
||||
"""
|
||||
|
||||
__slots__ = ("_path",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runner: web.BaseRunner,
|
||||
path: Path,
|
||||
*,
|
||||
backlog: int = 128,
|
||||
) -> None:
|
||||
"""Initialize HomeAssistantUnixSite."""
|
||||
super().__init__(
|
||||
runner,
|
||||
backlog=backlog,
|
||||
)
|
||||
self._path = path
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return server URL."""
|
||||
return f"http://unix:{self._path}:"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start server."""
|
||||
await super().start()
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._path.unlink(missing_ok=True)
|
||||
loop = asyncio.get_running_loop()
|
||||
server = self._runner.server
|
||||
assert server is not None
|
||||
self._server = await loop.create_unix_server(
|
||||
server,
|
||||
self._path,
|
||||
backlog=self._backlog,
|
||||
start_serving=False,
|
||||
)
|
||||
self._path.chmod(0o600)
|
||||
await self._server.start_serving()
|
||||
|
||||
@@ -6,7 +6,6 @@ from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -27,17 +26,6 @@ from .light import get_available_color_modes
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def device_filter(advertisement_data: AdvertisementData) -> bool:
|
||||
"""Return True if the device is supported."""
|
||||
return (
|
||||
SERVICE_UUID in advertisement_data.service_uuids
|
||||
and SERVICE_DATA_UUID in advertisement_data.service_data
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
|
||||
"""Return error if cannot connect and validate."""
|
||||
|
||||
@@ -82,66 +70,28 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
|
||||
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(user_input[CONF_MAC])
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in bluetooth.async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not device_filter(discovery.advertisement)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MAC): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name} ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the home assistant scanner."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"HA found light %s. Use user flow to show in UI and connect",
|
||||
"HA found light %s. Will show in UI but not auto connect",
|
||||
discovery_info.name,
|
||||
)
|
||||
return self.async_abort(reason="discovery_unsupported")
|
||||
|
||||
unique_id = dr.format_mac(discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
name = f"{discovery_info.name} ({discovery_info.address})"
|
||||
self.context.update({"title_placeholders": {CONF_NAME: name}})
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -153,10 +103,7 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(self._discovery_info.address)
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
error = await validate_input(self.hass, unique_id)
|
||||
if error:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
"not_implemented": "This integration can only be set up via discovery."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -15,16 +14,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"mac": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"mac": "Select the Hue device you want to set up"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ override_schedule:
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
enable_second: false
|
||||
override_mode:
|
||||
required: true
|
||||
example: "mow"
|
||||
@@ -33,7 +32,6 @@ override_schedule_work_area:
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
enable_second: false
|
||||
work_area_id:
|
||||
required: true
|
||||
example: "123"
|
||||
|
||||
@@ -511,7 +511,7 @@
|
||||
"description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "Minimum: 1 minute, maximum: 42 days.",
|
||||
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.",
|
||||
"name": "Duration"
|
||||
},
|
||||
"override_mode": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==2.0.2"]
|
||||
"requirements": ["imgw_pib==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"hydrological_alert": {
|
||||
"name": "Hydrological alert",
|
||||
"state": {
|
||||
"exceeding_the_alarm_level": "Exceeding the alarm level",
|
||||
"exceeding_the_warning_level": "Exceeding the warning level",
|
||||
"hydrological_drought": "Hydrological drought",
|
||||
"no_alert": "No alert",
|
||||
|
||||
@@ -7,12 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import IndevoltConfigEntry, IndevoltCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Select platform for Indevolt integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IndevoltSelectEntityDescription(SelectEntityDescription):
|
||||
"""Custom entity description class for Indevolt select entities."""
|
||||
|
||||
read_key: str
|
||||
write_key: str
|
||||
value_to_option: dict[int, str]
|
||||
unavailable_values: list[int] = field(default_factory=list)
|
||||
generation: list[int] = field(default_factory=lambda: [1, 2])
|
||||
|
||||
|
||||
SELECTS: Final = (
|
||||
IndevoltSelectEntityDescription(
|
||||
key="energy_mode",
|
||||
translation_key="energy_mode",
|
||||
read_key="7101",
|
||||
write_key="47005",
|
||||
value_to_option={
|
||||
1: "self_consumed_prioritized",
|
||||
4: "real_time_control",
|
||||
5: "charge_discharge_schedule",
|
||||
},
|
||||
unavailable_values=[0],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IndevoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the select platform for Indevolt."""
|
||||
coordinator = entry.runtime_data
|
||||
device_gen = coordinator.generation
|
||||
|
||||
# Select initialization
|
||||
async_add_entities(
|
||||
IndevoltSelectEntity(coordinator=coordinator, description=description)
|
||||
for description in SELECTS
|
||||
if device_gen in description.generation
|
||||
)
|
||||
|
||||
|
||||
class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
|
||||
"""Represents a select entity for Indevolt devices."""
|
||||
|
||||
entity_description: IndevoltSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IndevoltCoordinator,
|
||||
description: IndevoltSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Indevolt select entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{self.serial_number}_{description.key}"
|
||||
self._attr_options = list(description.value_to_option.values())
|
||||
self._option_to_value = {v: k for k, v in description.value_to_option.items()}
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the currently selected option."""
|
||||
raw_value = self.coordinator.data.get(self.entity_description.read_key)
|
||||
if raw_value is None:
|
||||
return None
|
||||
|
||||
return self.entity_description.value_to_option.get(raw_value)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return False when the device is in a mode that cannot be selected."""
|
||||
if not super().available:
|
||||
return False
|
||||
|
||||
raw_value = self.coordinator.data.get(self.entity_description.read_key)
|
||||
return raw_value not in self.entity_description.unavailable_values
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select a new option."""
|
||||
value = self._option_to_value[option]
|
||||
success = await self.coordinator.async_push_data(
|
||||
self.entity_description.write_key, value
|
||||
)
|
||||
|
||||
if success:
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")
|
||||
@@ -37,16 +37,6 @@
|
||||
"name": "Max AC output power"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"energy_mode": {
|
||||
"name": "[%key:component::indevolt::entity::sensor::energy_mode::name%]",
|
||||
"state": {
|
||||
"charge_discharge_schedule": "[%key:component::indevolt::entity::sensor::energy_mode::state::charge_discharge_schedule%]",
|
||||
"real_time_control": "[%key:component::indevolt::entity::sensor::energy_mode::state::real_time_control%]",
|
||||
"self_consumed_prioritized": "[%key:component::indevolt::entity::sensor::energy_mode::state::self_consumed_prioritized%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ac_input_power": {
|
||||
"name": "AC input power"
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import LiebherrClient
|
||||
from pyliebherrhomeapi.exceptions import (
|
||||
@@ -16,13 +14,8 @@ from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import DEVICE_SCAN_INTERVAL, DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
@@ -49,7 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
|
||||
|
||||
# Create a coordinator for each device (may be empty if no devices)
|
||||
data = LiebherrData(client=client)
|
||||
coordinators: dict[str, LiebherrCoordinator] = {}
|
||||
for device in devices:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
@@ -57,61 +50,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
coordinators[device.device_id] = coordinator
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in data.coordinators.values()
|
||||
for coordinator in coordinators.values()
|
||||
)
|
||||
)
|
||||
|
||||
# Store runtime data
|
||||
entry.runtime_data = data
|
||||
# Store coordinators in runtime data
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Schedule periodic scan for new devices
|
||||
async def _async_scan_for_new_devices(_now: datetime) -> None:
|
||||
"""Scan for new devices added to the account."""
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError, LiebherrConnectionError:
|
||||
_LOGGER.debug("Failed to scan for new devices")
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error scanning for new devices")
|
||||
return
|
||||
|
||||
new_coordinators: list[LiebherrCoordinator] = []
|
||||
for device in devices:
|
||||
if device.device_id not in data.coordinators:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
_LOGGER.debug("Failed to set up new device %s", device.device_id)
|
||||
continue
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
new_coordinators.append(coordinator)
|
||||
|
||||
if new_coordinators:
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
f"{DOMAIN}_new_device_{entry.entry_id}",
|
||||
new_coordinators,
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,4 @@ from typing import Final
|
||||
DOMAIN: Final = "liebherr"
|
||||
MANUFACTURER: Final = "Liebherr"
|
||||
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5)
|
||||
REFRESH_DELAY: Final = timedelta(seconds=5)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import (
|
||||
@@ -18,20 +18,13 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
from .const import DOMAIN
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LiebherrData:
|
||||
"""Runtime data for the Liebherr integration."""
|
||||
|
||||
client: LiebherrClient
|
||||
coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict)
|
||||
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[LiebherrData]
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
|
||||
@@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics(
|
||||
},
|
||||
"data": asdict(coordinator.data),
|
||||
}
|
||||
for device_id, coordinator in entry.runtime_data.coordinators.items()
|
||||
for device_id, coordinator in entry.runtime_data.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -55,41 +53,22 @@ NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_number_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrNumber]:
|
||||
"""Create number entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr number entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
_create_number_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add number entities for new devices."""
|
||||
async_add_entities(_create_number_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
|
||||
@@ -18,11 +18,9 @@ from pyliebherrhomeapi import (
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -111,13 +109,15 @@ SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
|
||||
]
|
||||
|
||||
|
||||
def _create_select_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSelectEntity]:
|
||||
"""Create select entities for the given coordinators."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
entities: list[LiebherrSelectEntity] = []
|
||||
|
||||
for coordinator in coordinators:
|
||||
for coordinator in entry.runtime_data.values():
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -137,29 +137,7 @@ def _create_select_entities(
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
async_add_entities(
|
||||
_create_select_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add select entities for new devices."""
|
||||
async_add_entities(_create_select_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
|
||||
|
||||
@@ -14,12 +14,10 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -50,41 +48,22 @@ SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_sensor_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSensor]:
|
||||
"""Create sensor entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr sensor entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
_create_sensor_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add sensor entities for new devices."""
|
||||
async_add_entities(_create_sensor_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,11 +15,9 @@ from pyliebherrhomeapi.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -92,13 +90,15 @@ DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = {
|
||||
}
|
||||
|
||||
|
||||
def _create_switch_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]:
|
||||
"""Create switch entities for the given coordinators."""
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = []
|
||||
|
||||
for coordinator in coordinators:
|
||||
for coordinator in entry.runtime_data.values():
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -127,29 +127,7 @@ def _create_switch_entities(
|
||||
)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
async_add_entities(
|
||||
_create_switch_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add switch entities for new devices."""
|
||||
async_add_entities(_create_switch_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
|
||||
|
||||
@@ -109,18 +109,14 @@ class LunatoneLight(
|
||||
return self._device is not None and self._device.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
def brightness(self) -> int:
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return (
|
||||
value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
if self._device.brightness is not None
|
||||
else None
|
||||
)
|
||||
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device is not None and self._device.brightness is not None:
|
||||
if self._device is not None and self._device.is_dimmable:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@@ -153,8 +149,7 @@ class LunatoneLight(
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn off."""
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
if self.brightness:
|
||||
self._last_brightness = self.brightness
|
||||
self._last_brightness = self.brightness
|
||||
await self._device.fade_to_brightness(0)
|
||||
else:
|
||||
await self._device.switch_off()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.7.0"]
|
||||
"requirements": ["lunatone-rest-api-client==0.6.3"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2026.02.21"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Diagnostics support for Met.no integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import MetWeatherConfigEntry
|
||||
|
||||
TO_REDACT = [
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MetWeatherConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator_data = entry.runtime_data.data
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": {
|
||||
"current_weather_data": coordinator_data.current_weather_data,
|
||||
"daily_forecast": coordinator_data.daily_forecast,
|
||||
"hourly_forecast": coordinator_data.hourly_forecast,
|
||||
},
|
||||
}
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN as DOMAIN, SUBENTRY_TYPE_BUS, SUBENTRY_TYPE_SUBWAY
|
||||
from .const import DOMAIN as DOMAIN
|
||||
from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
@@ -15,36 +13,16 @@ PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
|
||||
"""Set up MTA from a config entry."""
|
||||
coordinators: dict[str, MTADataUpdateCoordinator] = {}
|
||||
coordinator = MTADataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
for subentry_id, subentry in entry.subentries.items():
|
||||
if subentry.subentry_type not in (SUBENTRY_TYPE_SUBWAY, SUBENTRY_TYPE_BUS):
|
||||
continue
|
||||
|
||||
coordinators[subentry_id] = MTADataUpdateCoordinator(hass, entry, subentry)
|
||||
|
||||
# Refresh all coordinators in parallel
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators.values()
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> None:
|
||||
"""Handle config entry update (e.g., subentry changes)."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -2,43 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pymta import LINE_TO_FEED, BusFeed, MTAFeedError, SubwayFeed
|
||||
from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_LINE,
|
||||
CONF_ROUTE,
|
||||
CONF_STOP_ID,
|
||||
CONF_STOP_NAME,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_BUS,
|
||||
SUBENTRY_TYPE_SUBWAY,
|
||||
)
|
||||
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,79 +28,17 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this handler."""
|
||||
return {
|
||||
SUBENTRY_TYPE_SUBWAY: SubwaySubentryFlowHandler,
|
||||
SUBENTRY_TYPE_BUS: BusSubentryFlowHandler,
|
||||
}
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
api_key = user_input.get(CONF_API_KEY)
|
||||
self._async_abort_entries_match({CONF_API_KEY: api_key})
|
||||
if api_key:
|
||||
# Test the API key by trying to fetch bus data
|
||||
session = async_get_clientsession(self.hass)
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
try:
|
||||
# Try to get stops for a known route to validate the key
|
||||
await bus_feed.get_stops(route_id="M15")
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error validating API key")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: api_key or None},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title="MTA",
|
||||
data={CONF_API_KEY: api_key or None},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, _entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth when user wants to add or update API key."""
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subway stop subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the line selection step."""
|
||||
if user_input is not None:
|
||||
self.data[CONF_LINE] = user_input[CONF_LINE]
|
||||
return await self.async_step_stop()
|
||||
@@ -141,12 +58,13 @@ class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_stop(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the stop selection step."""
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the stop step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
@@ -156,30 +74,25 @@ class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
self.data[CONF_STOP_NAME] = stop_name
|
||||
|
||||
unique_id = f"{self.data[CONF_LINE]}_{stop_id}"
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Check for duplicate subentries across all entries
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connection to real-time GTFS-RT feed
|
||||
# Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops)
|
||||
try:
|
||||
await self._async_test_connection()
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
title = f"{self.data[CONF_LINE]} - {stop_name}"
|
||||
title = f"{self.data[CONF_LINE]} Line - {stop_name}"
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self.data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
try:
|
||||
self.stops = await self._async_get_stops(self.data[CONF_LINE])
|
||||
except MTAFeedError:
|
||||
_LOGGER.debug("Error fetching stops for line %s", self.data[CONF_LINE])
|
||||
_LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE])
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
if not self.stops:
|
||||
@@ -210,7 +123,7 @@ class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
async def _async_get_stops(self, line: str) -> dict[str, str]:
|
||||
"""Get stops for a line from the library."""
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(line)
|
||||
session = async_get_clientsession(self.hass)
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
stops_list = await subway_feed.get_stops(route_id=line)
|
||||
@@ -228,7 +141,7 @@ class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
async def _async_test_connection(self) -> None:
|
||||
"""Test connection to MTA feed."""
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE])
|
||||
session = async_get_clientsession(self.hass)
|
||||
session = aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
await subway_feed.get_arrivals(
|
||||
@@ -236,133 +149,3 @@ class SubwaySubentryFlowHandler(ConfigSubentryFlow):
|
||||
stop_id=self.data[CONF_STOP_ID],
|
||||
max_arrivals=1,
|
||||
)
|
||||
|
||||
|
||||
class BusSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle bus stop subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
self.stops: dict[str, str] = {}
|
||||
|
||||
def _get_api_key(self) -> str:
|
||||
"""Get API key from parent entry."""
|
||||
return self._get_entry().data.get(CONF_API_KEY) or ""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the route input step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
route = user_input[CONF_ROUTE].upper().strip()
|
||||
self.data[CONF_ROUTE] = route
|
||||
|
||||
# Validate route by fetching stops
|
||||
try:
|
||||
self.stops = await self._async_get_stops(route)
|
||||
if not self.stops:
|
||||
errors["base"] = "invalid_route"
|
||||
else:
|
||||
return await self.async_step_stop()
|
||||
except MTAFeedError:
|
||||
_LOGGER.debug("Error fetching stops for route %s", route)
|
||||
errors["base"] = "invalid_route"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ROUTE): TextSelector(),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_stop(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the stop selection step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
stop_id = user_input[CONF_STOP_ID]
|
||||
self.data[CONF_STOP_ID] = stop_id
|
||||
stop_name = self.stops.get(stop_id, stop_id)
|
||||
self.data[CONF_STOP_NAME] = stop_name
|
||||
|
||||
unique_id = f"bus_{self.data[CONF_ROUTE]}_{stop_id}"
|
||||
|
||||
# Check for duplicate subentries across all entries
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connection to real-time feed
|
||||
try:
|
||||
await self._async_test_connection()
|
||||
except MTAFeedError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
title = f"{self.data[CONF_ROUTE]} - {stop_name}"
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data=self.data,
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
stop_options = [
|
||||
SelectOptionDict(value=stop_id, label=stop_name)
|
||||
for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1])
|
||||
]
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="stop",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_STOP_ID): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=stop_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"route": self.data[CONF_ROUTE]},
|
||||
)
|
||||
|
||||
async def _async_get_stops(self, route: str) -> dict[str, str]:
|
||||
"""Get stops for a bus route from the library."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api_key = self._get_api_key()
|
||||
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
stops_list = await bus_feed.get_stops(route_id=route)
|
||||
|
||||
stops = {}
|
||||
for stop in stops_list:
|
||||
stop_id = stop["stop_id"]
|
||||
stop_name = stop["stop_name"]
|
||||
# Add direction if available (e.g., "to South Ferry")
|
||||
if direction := stop.get("direction_name"):
|
||||
stops[stop_id] = f"{stop_name} (to {direction})"
|
||||
else:
|
||||
stops[stop_id] = stop_name
|
||||
|
||||
return stops
|
||||
|
||||
async def _async_test_connection(self) -> None:
|
||||
"""Test connection to MTA bus feed."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
api_key = self._get_api_key()
|
||||
|
||||
bus_feed = BusFeed(api_key=api_key, session=session)
|
||||
await bus_feed.get_arrivals(
|
||||
route_id=self.data[CONF_ROUTE],
|
||||
stop_id=self.data[CONF_STOP_ID],
|
||||
max_arrivals=1,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,5 @@ DOMAIN = "mta"
|
||||
CONF_LINE = "line"
|
||||
CONF_STOP_ID = "stop_id"
|
||||
CONF_STOP_NAME = "stop_name"
|
||||
CONF_ROUTE = "route"
|
||||
|
||||
SUBENTRY_TYPE_SUBWAY = "subway"
|
||||
SUBENTRY_TYPE_BUS = "bus"
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
@@ -6,30 +6,22 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from pymta import BusFeed, MTAFeedError, SubwayFeed
|
||||
from pymta import MTAFeedError, SubwayFeed
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_LINE,
|
||||
CONF_ROUTE,
|
||||
CONF_STOP_ID,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_BUS,
|
||||
UPDATE_INTERVAL,
|
||||
)
|
||||
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MTAArrival:
|
||||
"""Represents a single transit arrival."""
|
||||
"""Represents a single train arrival."""
|
||||
|
||||
arrival_time: datetime
|
||||
minutes_until: int
|
||||
@@ -44,7 +36,7 @@ class MTAData:
|
||||
arrivals: list[MTAArrival]
|
||||
|
||||
|
||||
type MTAConfigEntry = ConfigEntry[dict[str, MTADataUpdateCoordinator]]
|
||||
type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator]
|
||||
|
||||
|
||||
class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
|
||||
@@ -52,48 +44,35 @@ class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
|
||||
|
||||
config_entry: MTAConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MTAConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None:
|
||||
"""Initialize."""
|
||||
self.subentry = subentry
|
||||
self.stop_id = subentry.data[CONF_STOP_ID]
|
||||
self.line = config_entry.data[CONF_LINE]
|
||||
self.stop_id = config_entry.data[CONF_STOP_ID]
|
||||
|
||||
self.feed_id = SubwayFeed.get_feed_id_for_route(self.line)
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_BUS:
|
||||
api_key = config_entry.data.get(CONF_API_KEY) or ""
|
||||
self.feed: BusFeed | SubwayFeed = BusFeed(api_key=api_key, session=session)
|
||||
self.route_id = subentry.data[CONF_ROUTE]
|
||||
else:
|
||||
# Subway feed
|
||||
line = subentry.data[CONF_LINE]
|
||||
feed_id = SubwayFeed.get_feed_id_for_route(line)
|
||||
self.feed = SubwayFeed(feed_id=feed_id, session=session)
|
||||
self.route_id = line
|
||||
self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_{subentry.subentry_id}",
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> MTAData:
|
||||
"""Fetch data from MTA."""
|
||||
_LOGGER.debug(
|
||||
"Fetching data for route=%s, stop=%s",
|
||||
self.route_id,
|
||||
"Fetching data for line=%s, stop=%s, feed=%s",
|
||||
self.line,
|
||||
self.stop_id,
|
||||
self.feed_id,
|
||||
)
|
||||
|
||||
try:
|
||||
library_arrivals = await self.feed.get_arrivals(
|
||||
route_id=self.route_id,
|
||||
library_arrivals = await self.subway_feed.get_arrivals(
|
||||
route_id=self.line,
|
||||
stop_id=self.stop_id,
|
||||
max_arrivals=3,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,9 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: No authentication required.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -11,13 +11,12 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_LINE, CONF_ROUTE, CONF_STOP_NAME, DOMAIN, SUBENTRY_TYPE_BUS
|
||||
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
|
||||
from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -98,19 +97,16 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up MTA sensor based on a config entry."""
|
||||
for subentry_id, coordinator in entry.runtime_data.items():
|
||||
subentry = entry.subentries[subentry_id]
|
||||
async_add_entities(
|
||||
(
|
||||
MTASensor(coordinator, subentry, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry_id,
|
||||
)
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
MTASensor(coordinator, entry, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
|
||||
"""Sensor for MTA transit arrivals."""
|
||||
"""Sensor for MTA train arrivals."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: MTASensorEntityDescription
|
||||
@@ -118,32 +114,24 @@ class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MTADataUpdateCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
entry: MTAConfigEntry,
|
||||
description: MTASensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self.entity_description = description
|
||||
line = entry.data[CONF_LINE]
|
||||
stop_id = entry.data[CONF_STOP_ID]
|
||||
stop_name = entry.data.get(CONF_STOP_NAME, stop_id)
|
||||
|
||||
is_bus = subentry.subentry_type == SUBENTRY_TYPE_BUS
|
||||
if is_bus:
|
||||
route = subentry.data[CONF_ROUTE]
|
||||
model = "Bus"
|
||||
else:
|
||||
route = subentry.data[CONF_LINE]
|
||||
model = "Subway"
|
||||
|
||||
stop_name = subentry.data.get(CONF_STOP_NAME, subentry.subentry_id)
|
||||
|
||||
unique_id = subentry.unique_id or subentry.subentry_id
|
||||
self._attr_unique_id = f"{unique_id}-{description.key}"
|
||||
self._attr_unique_id = f"{entry.unique_id}-{description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"{route} - {stop_name}",
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=f"{line} Line - {stop_name} ({stop_id})",
|
||||
manufacturer="MTA",
|
||||
model=model,
|
||||
model="Subway",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,95 +2,32 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_stops": "No stops found for this line. The line may not be currently running."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"stop_id": "Stop and direction"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "API key from MTA Bus Time. Required for bus tracking, optional for subway only."
|
||||
"stop_id": "Select the stop and direction you want to track"
|
||||
},
|
||||
"description": "Enter your MTA Bus Time API key to enable bus tracking. Leave blank if you only want to track subways."
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"bus": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
|
||||
"title": "Select stop and direction"
|
||||
},
|
||||
"entry_type": "Bus stop",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_route": "Invalid bus route. Please check the route name and try again."
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add bus stop"
|
||||
},
|
||||
"step": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"stop_id": "Stop"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Select the stop you want to track"
|
||||
},
|
||||
"description": "Choose a stop on the {route} route.",
|
||||
"title": "Select stop"
|
||||
"user": {
|
||||
"data": {
|
||||
"line": "Line"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"route": "Route"
|
||||
},
|
||||
"data_description": {
|
||||
"route": "The bus route identifier"
|
||||
},
|
||||
"description": "Enter the bus route you want to track (for example, M15, B46, Q10).",
|
||||
"title": "Enter bus route"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subway": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"no_stops": "No stops found for this line. The line may not be currently running."
|
||||
},
|
||||
"entry_type": "Subway stop",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add subway stop"
|
||||
},
|
||||
"step": {
|
||||
"stop": {
|
||||
"data": {
|
||||
"stop_id": "Stop and direction"
|
||||
},
|
||||
"data_description": {
|
||||
"stop_id": "Select the stop and direction you want to track"
|
||||
},
|
||||
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
|
||||
"title": "Select stop and direction"
|
||||
"data_description": {
|
||||
"line": "The subway line to track"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"line": "Line"
|
||||
},
|
||||
"data_description": {
|
||||
"line": "The subway line to track"
|
||||
},
|
||||
"description": "Choose the subway line you want to track.",
|
||||
"title": "Select subway line"
|
||||
}
|
||||
"description": "Choose the subway line you want to track.",
|
||||
"title": "Select subway line"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -120,31 +120,6 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._discovered_name: str | None = None
|
||||
self._pending_host: str | None = None
|
||||
|
||||
async def _async_validate_host(
|
||||
self,
|
||||
host: str,
|
||||
errors: dict[str, str],
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
"""Validate host connection and populate errors dict on failure.
|
||||
|
||||
Returns (info, needs_auth). When needs_auth is True, the caller
|
||||
should store the host and redirect to the appropriate auth step.
|
||||
"""
|
||||
try:
|
||||
return await validate_input(self.hass, host), False
|
||||
except NRGkickApiClientApiDisabledError:
|
||||
errors["base"] = "json_api_disabled"
|
||||
except NRGkickApiClientAuthenticationError:
|
||||
return None, True
|
||||
except NRGkickApiClientInvalidResponseError:
|
||||
errors["base"] = "invalid_response"
|
||||
except NRGkickApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except NRGkickApiClientError:
|
||||
_LOGGER.exception("Unexpected error")
|
||||
errors["base"] = "unknown"
|
||||
return None, False
|
||||
|
||||
async def _async_validate_credentials(
|
||||
self,
|
||||
host: str,
|
||||
@@ -181,11 +156,21 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except vol.Invalid:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
info, needs_auth = await self._async_validate_host(host, errors)
|
||||
if needs_auth:
|
||||
try:
|
||||
info = await validate_input(self.hass, host)
|
||||
except NRGkickApiClientApiDisabledError:
|
||||
errors["base"] = "json_api_disabled"
|
||||
except NRGkickApiClientAuthenticationError:
|
||||
self._pending_host = host
|
||||
return await self.async_step_user_auth()
|
||||
if info:
|
||||
except NRGkickApiClientInvalidResponseError:
|
||||
errors["base"] = "invalid_response"
|
||||
except NRGkickApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except NRGkickApiClientError:
|
||||
_LOGGER.exception("Unexpected error")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
info["serial"], raise_on_progress=False
|
||||
)
|
||||
@@ -213,8 +198,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if info := await self._async_validate_credentials(
|
||||
self._pending_host,
|
||||
errors,
|
||||
username=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -222,8 +207,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=info["title"],
|
||||
data={
|
||||
CONF_HOST: self._pending_host,
|
||||
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -253,8 +238,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if info := await self._async_validate_credentials(
|
||||
reauth_entry.data[CONF_HOST],
|
||||
errors,
|
||||
username=user_input.get(CONF_USERNAME),
|
||||
password=user_input.get(CONF_PASSWORD),
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
@@ -272,83 +257,6 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
try:
|
||||
host = _normalize_host(user_input[CONF_HOST])
|
||||
except vol.Invalid:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
info, needs_auth = await self._async_validate_host(host, errors)
|
||||
if needs_auth:
|
||||
self._pending_host = host
|
||||
return await self.async_step_reconfigure_auth()
|
||||
if info:
|
||||
await self.async_set_unique_id(
|
||||
info["serial"], raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={CONF_HOST: host},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA,
|
||||
reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration authentication step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert self._pending_host is not None
|
||||
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
if user_input is not None:
|
||||
username = user_input.get(CONF_USERNAME)
|
||||
password = user_input.get(CONF_PASSWORD)
|
||||
if info := await self._async_validate_credentials(
|
||||
self._pending_host,
|
||||
errors,
|
||||
username=username,
|
||||
password=password,
|
||||
):
|
||||
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_HOST: self._pending_host,
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: password,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_auth",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_AUTH_DATA_SCHEMA,
|
||||
reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"device_ip": self._pending_host,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -413,13 +321,21 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
assert self._discovered_name is not None
|
||||
|
||||
if user_input is not None:
|
||||
info, needs_auth = await self._async_validate_host(
|
||||
self._discovered_host, errors
|
||||
)
|
||||
if needs_auth:
|
||||
try:
|
||||
info = await validate_input(self.hass, self._discovered_host)
|
||||
except NRGkickApiClientApiDisabledError:
|
||||
errors["base"] = "json_api_disabled"
|
||||
except NRGkickApiClientAuthenticationError:
|
||||
self._pending_host = self._discovered_host
|
||||
return await self.async_step_user_auth()
|
||||
if info:
|
||||
except NRGkickApiClientInvalidResponseError:
|
||||
errors["base"] = "invalid_response"
|
||||
except NRGkickApiClientCommunicationError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except NRGkickApiClientError:
|
||||
_LOGGER.exception("Unexpected error")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=info["title"], data={CONF_HOST: self._discovered_host}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nrgkick",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["nrgkick-api==1.7.1"],
|
||||
"zeroconf": ["_nrgkick._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
@@ -68,7 +68,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"json_api_disabled": "JSON API is disabled on the device. Enable it in the NRGkick mobile app under Extended \u2192 Local API \u2192 API Variants.",
|
||||
"no_serial_number": "Device does not provide a serial number",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The device does not match the previous device"
|
||||
},
|
||||
"error": {
|
||||
@@ -29,26 +28,6 @@
|
||||
},
|
||||
"description": "Reauthenticate with your NRGkick device.\n\nGet your username and password in the NRGkick mobile app:\n1. Open the NRGkick mobile app \u2192 Extended \u2192 Local API\n2. Under Authentication (JSON), check or set your username and password"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::nrgkick::config::step::user::data_description::host%]"
|
||||
},
|
||||
"description": "Reconfigure your NRGkick device. This allows you to change the IP address or hostname of your NRGkick device."
|
||||
},
|
||||
"reconfigure_auth": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::nrgkick::config::step::user_auth::data_description::password%]",
|
||||
"username": "[%key:component::nrgkick::config::step::user_auth::data_description::username%]"
|
||||
},
|
||||
"description": "[%key:component::nrgkick::config::step::user_auth::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -394,10 +394,10 @@
|
||||
"name": "Delete notification"
|
||||
},
|
||||
"publish": {
|
||||
"description": "Publishes a notification message to a ntfy topic.",
|
||||
"description": "Publishes a notification message to a ntfy topic",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
|
||||
"description": "Up to three actions ('view', 'broadcast', or 'http') can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"attach": {
|
||||
|
||||
@@ -50,7 +50,6 @@ from .const import (
|
||||
CONF_TOP_P,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_STT_NAME,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
@@ -58,7 +57,6 @@ from .const import (
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -68,7 +66,7 @@ from .entity import async_prepare_files_for_prompt
|
||||
SERVICE_GENERATE_IMAGE = "generate_image"
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.STT, Platform.TTS)
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
|
||||
@@ -482,10 +480,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
|
||||
_add_tts_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=5)
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 5:
|
||||
_add_stt_subentry(hass, entry)
|
||||
hass.config_entries.async_update_entry(entry, minor_version=6)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
@@ -506,19 +500,6 @@ def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None
|
||||
)
|
||||
|
||||
|
||||
def _add_stt_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Add STT subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_STT_OPTIONS),
|
||||
subentry_type="stt",
|
||||
title=DEFAULT_STT_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _add_tts_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Add TTS subentry to the config entry."""
|
||||
hass.config_entries.async_add_subentry(
|
||||
|
||||
@@ -68,8 +68,6 @@ from .const import (
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_STT_NAME,
|
||||
DEFAULT_STT_PROMPT,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
@@ -80,8 +78,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -114,14 +110,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
client = openai.AsyncOpenAI(
|
||||
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
|
||||
)
|
||||
await client.models.list(timeout=10.0)
|
||||
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
|
||||
|
||||
|
||||
class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 6
|
||||
MINOR_VERSION = 5
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -162,12 +158,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "stt",
|
||||
"data": RECOMMENDED_STT_OPTIONS,
|
||||
"title": DEFAULT_STT_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "tts",
|
||||
"data": RECOMMENDED_TTS_OPTIONS,
|
||||
@@ -214,7 +204,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return {
|
||||
"conversation": OpenAISubentryFlowHandler,
|
||||
"ai_task_data": OpenAISubentryFlowHandler,
|
||||
"stt": OpenAISubentrySTTFlowHandler,
|
||||
"tts": OpenAISubentryTTSFlowHandler,
|
||||
}
|
||||
|
||||
@@ -606,95 +595,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
return location_data
|
||||
|
||||
|
||||
class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing OpenAI STT subentries."""
|
||||
|
||||
options: dict[str, Any]
|
||||
|
||||
@property
|
||||
def _is_new(self) -> bool:
|
||||
"""Return if this is a new subentry."""
|
||||
return self.source == "user"
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.options = RECOMMENDED_STT_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.options = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Manage initial options."""
|
||||
# abort if entry is not loaded
|
||||
if self._get_entry().state != ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
|
||||
options = self.options
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
step_schema: VolDictType = {}
|
||||
|
||||
if self._is_new:
|
||||
step_schema[vol.Required(CONF_NAME, default=DEFAULT_STT_NAME)] = str
|
||||
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
|
||||
},
|
||||
): TextSelector(
|
||||
TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL, default=RECOMMENDED_STT_MODEL
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-transcribe",
|
||||
"whisper-1",
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
custom_value=True,
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
options.update(user_input)
|
||||
if not errors:
|
||||
if self._is_new:
|
||||
return self.async_create_entry(
|
||||
title=options.pop(CONF_NAME),
|
||||
data=options,
|
||||
)
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=options,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(step_schema), options
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing OpenAI TTS subentries."""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Constants for the OpenAI Conversation integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
@@ -11,7 +10,6 @@ LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
|
||||
DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
|
||||
DEFAULT_STT_NAME = "OpenAI STT"
|
||||
DEFAULT_TTS_NAME = "OpenAI TTS"
|
||||
DEFAULT_NAME = "OpenAI Conversation"
|
||||
|
||||
@@ -42,7 +40,6 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
|
||||
RECOMMENDED_MAX_TOKENS = 3000
|
||||
RECOMMENDED_REASONING_EFFORT = "low"
|
||||
RECOMMENDED_REASONING_SUMMARY = "auto"
|
||||
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
RECOMMENDED_TOP_P = 1.0
|
||||
RECOMMENDED_TTS_SPEED = 1.0
|
||||
@@ -51,9 +48,6 @@ RECOMMENDED_WEB_SEARCH = False
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
|
||||
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
|
||||
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS = False
|
||||
DEFAULT_STT_PROMPT = (
|
||||
"The following conversation is a smart home user talking to Home Assistant."
|
||||
)
|
||||
|
||||
UNSUPPORTED_MODELS: list[str] = [
|
||||
"o1-mini",
|
||||
@@ -114,7 +108,6 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
RECOMMENDED_STT_OPTIONS: dict[str, Any] = {}
|
||||
RECOMMENDED_TTS_OPTIONS = {
|
||||
CONF_PROMPT: "",
|
||||
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
|
||||
|
||||
@@ -92,7 +92,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_VERBOSITY,
|
||||
@@ -472,12 +471,7 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="OpenAI",
|
||||
model=subentry.data.get(
|
||||
CONF_CHAT_MODEL,
|
||||
RECOMMENDED_CHAT_MODEL
|
||||
if subentry.subentry_type != "stt"
|
||||
else RECOMMENDED_STT_MODEL,
|
||||
),
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
|
||||
@@ -146,30 +146,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"stt": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "Speech-to-text",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure speech-to-text service",
|
||||
"user": "Add speech-to-text service"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"chat_model": "Model",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]"
|
||||
},
|
||||
"data_description": {
|
||||
"chat_model": "The model to use to transcribe speech.",
|
||||
"prompt": "Use this prompt to improve the quality of the transcripts. Translate to the pipeline language for best results. See the documentation for more details."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
"""Speech to text support for OpenAI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterable
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
import wave
|
||||
|
||||
from openai import OpenAIError
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DEFAULT_STT_PROMPT,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
)
|
||||
from .entity import OpenAIBaseLLMEntity
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OpenAIConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: OpenAIConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up STT entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "stt":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[OpenAISTTEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class OpenAISTTEntity(stt.SpeechToTextEntity, OpenAIBaseLLMEntity):
|
||||
"""OpenAI Speech to text entity."""
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
# https://developers.openai.com/api/docs/guides/speech-to-text#supported-languages
|
||||
# The model may also transcribe the audio in other languages but with lower quality
|
||||
return [
|
||||
"af-ZA", # Afrikaans
|
||||
"ar-SA", # Arabic
|
||||
"hy-AM", # Armenian
|
||||
"az-AZ", # Azerbaijani
|
||||
"be-BY", # Belarusian
|
||||
"bs-BA", # Bosnian
|
||||
"bg-BG", # Bulgarian
|
||||
"ca-ES", # Catalan
|
||||
"zh-CN", # Chinese (Mandarin)
|
||||
"hr-HR", # Croatian
|
||||
"cs-CZ", # Czech
|
||||
"da-DK", # Danish
|
||||
"nl-NL", # Dutch
|
||||
"en-US", # English
|
||||
"et-EE", # Estonian
|
||||
"fi-FI", # Finnish
|
||||
"fr-FR", # French
|
||||
"gl-ES", # Galician
|
||||
"de-DE", # German
|
||||
"el-GR", # Greek
|
||||
"he-IL", # Hebrew
|
||||
"hi-IN", # Hindi
|
||||
"hu-HU", # Hungarian
|
||||
"is-IS", # Icelandic
|
||||
"id-ID", # Indonesian
|
||||
"it-IT", # Italian
|
||||
"ja-JP", # Japanese
|
||||
"kn-IN", # Kannada
|
||||
"kk-KZ", # Kazakh
|
||||
"ko-KR", # Korean
|
||||
"lv-LV", # Latvian
|
||||
"lt-LT", # Lithuanian
|
||||
"mk-MK", # Macedonian
|
||||
"ms-MY", # Malay
|
||||
"mr-IN", # Marathi
|
||||
"mi-NZ", # Maori
|
||||
"ne-NP", # Nepali
|
||||
"no-NO", # Norwegian
|
||||
"fa-IR", # Persian
|
||||
"pl-PL", # Polish
|
||||
"pt-PT", # Portuguese
|
||||
"ro-RO", # Romanian
|
||||
"ru-RU", # Russian
|
||||
"sr-RS", # Serbian
|
||||
"sk-SK", # Slovak
|
||||
"sl-SI", # Slovenian
|
||||
"es-ES", # Spanish
|
||||
"sw-KE", # Swahili
|
||||
"sv-SE", # Swedish
|
||||
"fil-PH", # Tagalog (Filipino)
|
||||
"ta-IN", # Tamil
|
||||
"th-TH", # Thai
|
||||
"tr-TR", # Turkish
|
||||
"uk-UA", # Ukrainian
|
||||
"ur-PK", # Urdu
|
||||
"vi-VN", # Vietnamese
|
||||
"cy-GB", # Welsh
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_formats(self) -> list[stt.AudioFormats]:
|
||||
"""Return a list of supported formats."""
|
||||
# https://developers.openai.com/api/docs/guides/speech-to-text#transcriptions
|
||||
return [stt.AudioFormats.WAV, stt.AudioFormats.OGG]
|
||||
|
||||
@property
|
||||
def supported_codecs(self) -> list[stt.AudioCodecs]:
|
||||
"""Return a list of supported codecs."""
|
||||
return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS]
|
||||
|
||||
@property
|
||||
def supported_bit_rates(self) -> list[stt.AudioBitRates]:
|
||||
"""Return a list of supported bit rates."""
|
||||
return [
|
||||
stt.AudioBitRates.BITRATE_8,
|
||||
stt.AudioBitRates.BITRATE_16,
|
||||
stt.AudioBitRates.BITRATE_24,
|
||||
stt.AudioBitRates.BITRATE_32,
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_sample_rates(self) -> list[stt.AudioSampleRates]:
|
||||
"""Return a list of supported sample rates."""
|
||||
return [
|
||||
stt.AudioSampleRates.SAMPLERATE_8000,
|
||||
stt.AudioSampleRates.SAMPLERATE_11000,
|
||||
stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
stt.AudioSampleRates.SAMPLERATE_18900,
|
||||
stt.AudioSampleRates.SAMPLERATE_22000,
|
||||
stt.AudioSampleRates.SAMPLERATE_32000,
|
||||
stt.AudioSampleRates.SAMPLERATE_37800,
|
||||
stt.AudioSampleRates.SAMPLERATE_44100,
|
||||
stt.AudioSampleRates.SAMPLERATE_48000,
|
||||
]
|
||||
|
||||
@property
|
||||
def supported_channels(self) -> list[stt.AudioChannels]:
|
||||
"""Return a list of supported channels."""
|
||||
return [stt.AudioChannels.CHANNEL_MONO, stt.AudioChannels.CHANNEL_STEREO]
|
||||
|
||||
async def async_process_audio_stream(
|
||||
self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes]
|
||||
) -> stt.SpeechResult:
|
||||
"""Process an audio stream to STT service."""
|
||||
audio_bytes = bytearray()
|
||||
async for chunk in stream:
|
||||
audio_bytes.extend(chunk)
|
||||
audio_data = bytes(audio_bytes)
|
||||
if metadata.format == stt.AudioFormats.WAV:
|
||||
# Add missing wav header
|
||||
wav_buffer = io.BytesIO()
|
||||
|
||||
with wave.open(wav_buffer, "wb") as wf:
|
||||
wf.setnchannels(metadata.channel.value)
|
||||
wf.setsampwidth(metadata.bit_rate.value // 8)
|
||||
wf.setframerate(metadata.sample_rate.value)
|
||||
wf.writeframes(audio_data)
|
||||
|
||||
audio_data = wav_buffer.getvalue()
|
||||
|
||||
options = self.subentry.data
|
||||
client = self.entry.runtime_data
|
||||
|
||||
try:
|
||||
response = await client.audio.transcriptions.create(
|
||||
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
|
||||
file=(f"a.{metadata.format.value}", audio_data),
|
||||
response_format="json",
|
||||
language=metadata.language.split("-")[0],
|
||||
prompt=options.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
|
||||
)
|
||||
except OpenAIError:
|
||||
_LOGGER.exception("Error during STT")
|
||||
else:
|
||||
if response.text:
|
||||
return stt.SpeechResult(
|
||||
response.text,
|
||||
stt.SpeechResultState.SUCCESS,
|
||||
)
|
||||
|
||||
return stt.SpeechResult(None, stt.SpeechResultState.ERROR)
|
||||
@@ -15,14 +15,12 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import CONTAINER_STATE_RUNNING, STACK_STATUS_ACTIVE
|
||||
from .const import CONTAINER_STATE_RUNNING
|
||||
from .coordinator import PortainerContainerData, PortainerCoordinator
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
PortainerCoordinatorData,
|
||||
PortainerEndpointEntity,
|
||||
PortainerStackData,
|
||||
PortainerStackEntity,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -42,13 +40,6 @@ class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescripti
|
||||
state_fn: Callable[[PortainerCoordinatorData], bool | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Class to hold Portainer stack binary sensor description."""
|
||||
|
||||
state_fn: Callable[[PortainerStackData], bool | None]
|
||||
|
||||
|
||||
CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = (
|
||||
PortainerContainerBinarySensorEntityDescription(
|
||||
key="status",
|
||||
@@ -69,18 +60,6 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
STACK_SENSORS: tuple[PortainerStackBinarySensorEntityDescription, ...] = (
|
||||
PortainerStackBinarySensorEntityDescription(
|
||||
key="stack_status",
|
||||
translation_key="status",
|
||||
state_fn=lambda data: (
|
||||
data.stack.status == STACK_STATUS_ACTIVE
|
||||
), # 1 = Active | 2 = Inactive
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -119,24 +98,9 @@ async def async_setup_entry(
|
||||
if entity_description.state_fn(container)
|
||||
)
|
||||
|
||||
def _async_add_new_stacks(
|
||||
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
|
||||
) -> None:
|
||||
"""Add new stack sensors."""
|
||||
async_add_entities(
|
||||
PortainerStackSensor(
|
||||
coordinator,
|
||||
entity_description,
|
||||
stack,
|
||||
endpoint,
|
||||
)
|
||||
for (endpoint, stack) in stacks
|
||||
for entity_description in STACK_SENSORS
|
||||
)
|
||||
|
||||
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
|
||||
coordinator.new_containers_callbacks.append(_async_add_new_containers)
|
||||
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
|
||||
|
||||
_async_add_new_endpoints(
|
||||
[
|
||||
endpoint
|
||||
@@ -151,13 +115,6 @@ async def async_setup_entry(
|
||||
for container in endpoint.containers.values()
|
||||
]
|
||||
)
|
||||
_async_add_new_stacks(
|
||||
[
|
||||
(endpoint, stack)
|
||||
for endpoint in coordinator.data.values()
|
||||
for stack in endpoint.stacks.values()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
|
||||
@@ -205,27 +162,3 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.container_data)
|
||||
|
||||
|
||||
class PortainerStackSensor(PortainerStackEntity, BinarySensorEntity):
|
||||
"""Representation of a Portainer stack sensor."""
|
||||
|
||||
entity_description: PortainerStackBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: PortainerCoordinator,
|
||||
entity_description: PortainerStackBinarySensorEntityDescription,
|
||||
device_info: PortainerStackData,
|
||||
via_device: PortainerCoordinatorData,
|
||||
) -> None:
|
||||
"""Initialize the Portainer stack sensor."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(device_info, coordinator, via_device)
|
||||
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.entity_description.state_fn(self.stack_data)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user