Compare commits

..

6 Commits

Author SHA1 Message Date
Ludovic BOUÉ
8f5a8e572d Enhance v1 status refresh test to verify call order of refresh methods 2026-02-22 18:12:29 +01:00
Ludovic BOUÉ
f04c6295f2 Merge branch 'dev' into feat/roborock-v1-status-refresh-after-command 2026-02-22 17:55:55 +01:00
Ludovic BOUÉ
9c3c7b1360 Add test for v1 status refresh fallback 2026-02-22 07:16:10 +01:00
Ludovic BOUÉ
f459b91b19 Strengthen v1 status refresh ordering test 2026-02-21 21:22:15 +01:00
Ludovic BOUÉ
9621c0a8c4 Avoid duplicate sync refresh after v1 vacuum commands 2026-02-21 21:17:18 +01:00
Ludovic BOUÉ
a10cb2359e Refresh v1 status immediately after vacuum commands 2026-02-21 20:59:21 +01:00
543 changed files with 3344 additions and 35571 deletions

View File

@@ -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 \

View File

@@ -583,7 +583,6 @@ homeassistant.components.vacuum.*
homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
@@ -613,7 +612,6 @@ homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*
homeassistant.components.youtube.*
homeassistant.components.zeroconf.*
homeassistant.components.zinvolt.*
homeassistant.components.zodiac.*
homeassistant.components.zone.*
homeassistant.components.zwave_js.*

15
CODEOWNERS generated
View File

@@ -403,8 +403,8 @@ build.json @home-assistant/supervisor
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k
@@ -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
@@ -1080,8 +1082,6 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff
@@ -1880,8 +1880,8 @@ build.json @home-assistant/supervisor
/tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/weheat/ @barryvdh
/tests/components/weheat/ @barryvdh
/homeassistant/components/weheat/ @jesperraemaekers
/tests/components/weheat/ @jesperraemaekers
/homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis @mkmer
@@ -1959,14 +1959,11 @@ build.json @home-assistant/supervisor
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon
/homeassistant/components/zinvolt/ @joostlek
/tests/components/zinvolt/ @joostlek
/homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant
/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

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
import hashlib
import json
import logging
from pathlib import Path
@@ -39,6 +40,17 @@ class RestoreBackupFileContent:
restore_homeassistant: bool
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password.
Matches the implementation in supervisor.backups.utils.password_to_key.
"""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
@@ -84,14 +96,15 @@ def _extract_backup(
"""Extract the backup file to the config directory."""
with (
TemporaryDirectory() as tempdir,
securetar.SecureTarArchive(
securetar.SecureTarFile(
restore_content.backup_file_path,
gzip=False,
mode="r",
) as ostf,
):
ostf.tar.extractall(
ostf.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
members=securetar.secure_path(ostf),
filter="fully_trusted",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
@@ -113,7 +126,10 @@ def _extract_backup(
f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
),
gzip=backup_meta["compressed"],
password=restore_content.password,
key=password_to_key(restore_content.password)
if restore_content.password is not None
else None,
mode="r",
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),

View File

@@ -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
@@ -37,7 +23,6 @@ from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
]
@@ -53,40 +38,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

View File

@@ -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."""

View File

@@ -1,69 +0,0 @@
"""AirOS button component for Home Assistant."""
from __future__ import annotations
from airos.exceptions import AirOSException
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
PARALLEL_UPDATES = 0
REBOOT_BUTTON = ButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_registry_enabled_default=False,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AirOS button from a config entry."""
async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)])
class AirOSRebootButton(AirOSEntity, ButtonEntity):
"""Button to reboot device."""
entity_description: ButtonEntityDescription
def __init__(
self,
coordinator: AirOSDataUpdateCoordinator,
description: ButtonEntityDescription,
) -> None:
"""Initialize the AirOS client button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.derived.mac}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press to reboot the device."""
try:
await self.coordinator.airos_device.login()
result = await self.coordinator.airos_device.reboot()
except AirOSException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
if not result:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reboot_failed",
) from None

View File

@@ -2,24 +2,17 @@
from __future__ import annotations
import asyncio
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,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
import voluptuous as vol
from homeassistant.config_entries import (
@@ -37,36 +30,21 @@ 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,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
DEVICE_NAME,
DOMAIN,
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, 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
STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
@@ -80,10 +58,6 @@ STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
}
)
STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend(
{vol.Required(CONF_HOST): str}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
@@ -91,29 +65,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
MINOR_VERSION = 1
_discovery_task: asyncio.Task | None = None
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
self.selected_device_info: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self.errors = {}
return self.async_show_menu(
step_id="user", menu_options=["discovery", "manual"]
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the manual input of host and credentials."""
self.errors = {}
@@ -125,7 +84,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
data=validated_info["data"],
)
return self.async_show_form(
step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=self.errors
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
)
async def _validate_and_get_device_info(
@@ -139,14 +98,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 +122,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
@@ -259,175 +220,3 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=self.errors,
)
async def async_step_discovery(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Start the discovery process."""
if self._discovery_task and self._discovery_task.done():
self._discovery_task = None
# Handle appropriate 'errors' as abort through progress_done
if self.discovery_abort_reason:
return self.async_show_progress_done(
next_step_id=self.discovery_abort_reason
)
# Abort through progress_done if no devices were found
if not self.discovered_devices:
_LOGGER.debug(
"No (new or unconfigured) airOS devices found during discovery"
)
return self.async_show_progress_done(
next_step_id="discovery_no_devices"
)
# Skip selecting a device if only one new/unconfigured device was found
if len(self.discovered_devices) == 1:
self.selected_device_info = list(self.discovered_devices.values())[0]
return self.async_show_progress_done(next_step_id="configure_device")
return self.async_show_progress_done(next_step_id="select_device")
if not self._discovery_task:
self.discovered_devices = {}
self._discovery_task = self.hass.async_create_task(
self._async_run_discovery_with_progress()
)
# Show the progress bar and wait for discovery to complete
return self.async_show_progress(
step_id="discovery",
progress_action="discovering",
progress_task=self._discovery_task,
description_placeholders={"seconds": str(DISCOVER_INTERVAL)},
)
async def async_step_select_device(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Select a discovered device."""
if discovery_info is not None:
selected_mac = discovery_info[MAC_ADDRESS]
self.selected_device_info = self.discovered_devices[selected_mac]
return await self.async_step_configure_device()
list_options = {
mac: f"{device.get(HOSTNAME, mac)} ({device.get(IP_ADDRESS, DEVICE_NAME)})"
for mac, device in self.discovered_devices.items()
}
return self.async_show_form(
step_id="select_device",
data_schema=vol.Schema({vol.Required(MAC_ADDRESS): vol.In(list_options)}),
)
async def async_step_configure_device(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Configure the selected device."""
self.errors = {}
if user_input is not None:
config_data = {
**user_input,
CONF_HOST: self.selected_device_info[IP_ADDRESS],
}
validated_info = await self._validate_and_get_device_info(config_data)
if validated_info:
return self.async_create_entry(
title=validated_info["title"],
data=validated_info["data"],
)
device_name = self.selected_device_info.get(
HOSTNAME, self.selected_device_info.get(IP_ADDRESS, DEVICE_NAME)
)
return self.async_show_form(
step_id="configure_device",
data_schema=STEP_DISCOVERY_DATA_SCHEMA,
errors=self.errors,
description_placeholders={"device_name": device_name},
)
async def _async_run_discovery_with_progress(self) -> None:
"""Run discovery with an embedded progress update loop."""
progress_bar = self.hass.async_create_task(self._async_update_progress_bar())
known_mac_addresses = {
entry.unique_id.lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id
}
try:
devices = await airos_discover_devices(DISCOVER_INTERVAL)
except AirOSEndpointError:
self.discovery_abort_reason = "discovery_detect_error"
except AirOSListenerError:
self.discovery_abort_reason = "discovery_listen_error"
except Exception:
self.discovery_abort_reason = "discovery_failed"
_LOGGER.exception("An error occurred during discovery")
else:
self.discovered_devices = {
mac_addr: info
for mac_addr, info in devices.items()
if mac_addr.lower() not in known_mac_addresses
}
_LOGGER.debug(
"Discovery task finished. Found %s new devices",
len(self.discovered_devices),
)
finally:
progress_bar.cancel()
async def _async_update_progress_bar(self) -> None:
"""Update progress bar every second."""
try:
for i in range(DISCOVER_INTERVAL):
progress = (i + 1) / DISCOVER_INTERVAL
self.async_update_progress(progress)
await asyncio.sleep(1)
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:
"""Abort if discovery finds no (unconfigured) devices."""
return self.async_abort(reason="no_devices_found")
async def async_step_discovery_listen_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery is unable to listen on the port."""
return self.async_abort(reason="listen_error")
async def async_step_discovery_detect_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery receives incorrect broadcasts."""
return self.async_abort(reason="detect_error")
async def async_step_discovery_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery fails for other reasons."""
return self.async_abort(reason="discovery_failed")

View File

@@ -12,10 +12,3 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
HOSTNAME = "hostname"
IP_ADDRESS = "ip_address"
MAC_ADDRESS = "mac_address"
DEVICE_NAME = "airOS device"

View File

@@ -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,

View File

@@ -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",
"requirements": ["airos==0.6.4"]
"quality_scale": "silver",
"requirements": ["airos==0.6.3"]
}

View File

@@ -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

View File

@@ -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):

View File

@@ -2,10 +2,6 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"detect_error": "Unable to process discovered devices data, check the documentation for supported devices",
"discovery_failed": "Unable to start discovery, check logs for details",
"listen_error": "Unable to start listening for devices",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
@@ -17,36 +13,37 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Ubiquiti airOS device",
"progress": {
"connecting": "Connecting to the airOS device",
"discovering": "Listening for any airOS devices for {seconds} seconds"
},
"step": {
"configure_device": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]",
"username": "[%key:component::airos::config::step::manual::data_description::username%]"
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
},
"description": "Enter the username and password for {device_name}",
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
"name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]"
}
}
},
"manual": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
@@ -70,49 +67,6 @@
"name": "Advanced settings"
}
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
"select_device": {
"data": {
"mac_address": "Select the device to configure"
},
"data_description": {
"mac_address": "Select the device MAC address"
}
},
"user": {
"menu_options": {
"discovery": "Listen for airOS devices on the network",
"manual": "Manually configure airOS device"
}
}
}
},
@@ -203,9 +157,6 @@
},
"key_data_missing": {
"message": "Key data not returned from device"
},
"reboot_failed": {
"message": "The device did not accept the reboot request. Try again, or check your device web interface for errors."
}
}
}

View File

@@ -2,12 +2,10 @@
from __future__ import annotations
import aiohttp
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
@@ -33,27 +31,11 @@ async def async_setup_entry(
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
client = AladdinConnectClient(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
try:
doors = await client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
doors = await client.get_doors()
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)

View File

@@ -11,18 +11,6 @@ API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigFlowAuth(Auth):
"""Provide Aladdin Connect Genie authentication for config flow validation."""
def __init__(self, websession: ClientSession, access_token: str) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(websession, API_URL, access_token, API_KEY)
async def async_get_access_token(self) -> str:
"""Return the access token."""
return self.access_token
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""

View File

@@ -4,14 +4,12 @@ from collections.abc import Mapping
import logging
from typing import Any
from genie_partner_sdk.client import AladdinConnectClient
import jwt
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers import config_entry_oauth2_flow
from .api import AsyncConfigFlowAuth
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -54,25 +52,11 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
try:
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
user_id = token["sub"]
except jwt.DecodeError, KeyError:
return self.async_abort(reason="oauth_error")
client = AladdinConnectClient(
AsyncConfigFlowAuth(
aiohttp_client.async_get_clientsession(self.hass),
data["token"]["access_token"],
)
# Extract the user ID from the JWT token's 'sub' field
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
try:
await client.get_doors()
except Exception: # noqa: BLE001
return self.async_abort(reason="cannot_connect")
user_id = token["sub"]
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:

View File

@@ -7,31 +7,39 @@ rules:
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
config-flow-test-coverage: todo
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-installation-instructions:
status: todo
comment: Documentation needs to be created.
docs-removal-instructions:
status: todo
comment: Documentation needs to be created.
entity-event-setup:
status: exempt
comment: Integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
test-before-configure:
status: todo
comment: Config flow does not currently test connection during setup.
test-before-setup: todo
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: done
status: todo
comment: Documentation needs to be created.
docs-installation-parameters:
status: todo
comment: Documentation needs to be created.
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
@@ -44,17 +52,29 @@ rules:
# Gold
devices: done
diagnostics: todo
discovery: done
discovery-update-info:
status: exempt
comment: Integration connects via the cloud and not locally.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
discovery: todo
discovery-update-info: todo
docs-data-update:
status: todo
comment: Documentation needs to be created.
docs-examples:
status: todo
comment: Documentation needs to be created.
docs-known-limitations:
status: todo
comment: Documentation needs to be created.
docs-supported-devices:
status: todo
comment: Documentation needs to be created.
docs-supported-functions:
status: todo
comment: Documentation needs to be created.
docs-troubleshooting:
status: todo
comment: Documentation needs to be created.
docs-use-cases:
status: todo
comment: Documentation needs to be created.
dynamic-devices: todo
entity-category: done
entity-device-class: done
@@ -66,7 +86,7 @@ rules:
repair-issues: todo
stale-devices:
status: todo
comment: We can automatically remove removed devices
comment: Stale devices can be done dynamically
# Platinum
async-dependency: todo

View File

@@ -4,7 +4,6 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",

View File

@@ -75,7 +75,7 @@
"name": "Go to preset"
},
"ptz_control": {
"description": "Moves (pan/tilt) and/or zooms a PTZ camera.",
"description": "Moves (pan/tilt) and/or zoom a PTZ camera.",
"fields": {
"entity_id": {
"description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]",

View File

@@ -38,6 +38,7 @@ def get_app_entity_description(
translation_key="apps",
name=name_slug,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.apps.get(name_slug),
)
@@ -51,6 +52,7 @@ def get_core_integration_entity_description(
translation_key="core_integrations",
name=name,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.core_integrations.get(domain),
)
@@ -64,6 +66,7 @@ def get_custom_integration_entity_description(
translation_key="custom_integrations",
translation_placeholders={"custom_integration_domain": domain},
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.custom_integrations.get(domain),
)
@@ -74,6 +77,7 @@ GENERAL_SENSORS = [
translation_key="total_active_installations",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.active_installations,
),
AnalyticsSensorEntityDescription(
@@ -81,6 +85,7 @@ GENERAL_SENSORS = [
translation_key="total_reports_integrations",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement="active installations",
value_fn=lambda data: data.reports_integrations,
),
]

View File

@@ -24,23 +24,14 @@
},
"entity": {
"sensor": {
"apps": {
"unit_of_measurement": "active installations"
},
"core_integrations": {
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
},
"custom_integrations": {
"name": "{custom_integration_domain} (custom)",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
"name": "{custom_integration_domain} (custom)"
},
"total_active_installations": {
"name": "Total active installations",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
"name": "Total active installations"
},
"total_reports_integrations": {
"name": "Total reported integrations",
"unit_of_measurement": "[%key:component::analytics_insights::entity::sensor::apps::unit_of_measurement%]"
"name": "Total reported integrations"
}
}
},

View File

@@ -112,12 +112,19 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id != "claude-3-haiku-20240307"
if model_info.id
not in (
"claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
)
and model_info.id[-2:-1] != "-"
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,

View File

@@ -37,6 +37,8 @@ DEFAULT = {
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku
"claude-3-opus",
"claude-3-haiku",
]
@@ -49,7 +51,7 @@ NON_ADAPTIVE_THINKING_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
"claude-3",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
@@ -58,13 +60,19 @@ UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
"claude-3",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]
DEPRECATED_MODELS = [
"claude-3",
"claude-3-5-haiku",
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-opus",
]

View File

@@ -132,21 +132,11 @@ class ContentDetails:
"""Native data for AssistantContent."""
citation_details: list[CitationDetails] = field(default_factory=list)
thinking_signature: str | None = None
redacted_thinking: str | None = None
def has_content(self) -> bool:
"""Check if there is any text content."""
"""Check if there is any content."""
return any(detail.length > 0 for detail in self.citation_details)
def __bool__(self) -> bool:
"""Check if there is any thinking content or citations."""
return (
self.thinking_signature is not None
or self.redacted_thinking is not None
or self.has_citations()
)
def has_citations(self) -> bool:
"""Check if there are any citations."""
return any(detail.citations for detail in self.citation_details)
@@ -256,28 +246,29 @@ def _convert_content(
content=[],
)
)
elif isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
]
if isinstance(content.native, ContentDetails):
if content.native.thinking_signature:
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.thinking_signature,
)
if isinstance(content.native, ThinkingBlock):
messages[-1]["content"].append( # type: ignore[union-attr]
ThinkingBlockParam(
type="thinking",
thinking=content.thinking_content or "",
signature=content.native.signature,
)
if content.native.redacted_thinking:
messages[-1]["content"].append( # type: ignore[union-attr]
RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.redacted_thinking,
)
)
elif isinstance(content.native, RedactedThinkingBlock):
redacted_thinking_block = RedactedThinkingBlockParam(
type="redacted_thinking",
data=content.native.data,
)
if isinstance(messages[-1]["content"], str):
messages[-1]["content"] = [
TextBlockParam(type="text", text=messages[-1]["content"]),
redacted_thinking_block,
]
else:
messages[-1]["content"].append( # type: ignore[attr-defined]
redacted_thinking_block
)
if content.content:
current_index = 0
for detail in (
@@ -318,7 +309,6 @@ def _convert_content(
text=content.content[current_index:],
)
)
if content.tool_calls:
messages[-1]["content"].extend( # type: ignore[union-attr]
[
@@ -338,14 +328,6 @@ def _convert_content(
for tool_call in content.tool_calls
]
)
if (
isinstance(messages[-1]["content"], list)
and len(messages[-1]["content"]) == 1
and messages[-1]["content"][0]["type"] == "text"
):
# If there is only one text block, simplify the content to a string
messages[-1]["content"] = messages[-1]["content"][0]["text"]
else:
# Note: We don't pass SystemContent here as its passed to the API as the prompt
raise TypeError(f"Unexpected content type: {type(content)}")
@@ -397,7 +379,8 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None
first_block: bool = True
has_native = False
first_block: bool
async for response in stream:
LOGGER.debug("Received response: %s", response)
@@ -418,12 +401,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
@@ -434,11 +418,12 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
and content_details.has_content()
)
):
if content_details:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"}
has_native = False
first_block = False
content_details.add_citation_detail()
if response.content_block.text:
@@ -447,13 +432,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
)
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if first_block or content_details.thinking_signature:
if content_details:
if first_block or has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug(
@@ -461,15 +447,17 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if first_block or content_details.redacted_thinking:
if content_details:
if has_native:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
content_details.redacted_thinking = response.content_block.data
yield {"native": response.content_block}
has_native = True
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
@@ -479,7 +467,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):
if content_details:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
@@ -522,16 +510,19 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
else:
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
if response.delta.text:
content_details.citation_details[-1].length += len(
response.delta.text
)
yield {"content": response.delta.text}
content_details.citation_details[-1].length += len(response.delta.text)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
if response.delta.thinking:
yield {"thinking_content": response.delta.thinking}
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
content_details.thinking_signature = response.delta.signature
yield {
"native": ThinkingBlock(
type="thinking",
thinking="",
signature=response.delta.signature,
)
}
has_native = True
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
@@ -558,7 +549,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
if response.delta.stop_reason == "refusal":
raise HomeAssistantError("Potential policy violation detected")
elif isinstance(response, RawMessageStopEvent):
if content_details:
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()

View File

@@ -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"]
"requirements": ["anthropic==0.78.0"]
}

View File

@@ -10,7 +10,15 @@ rules:
Integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow-test-coverage:
status: todo
comment: |
* Remove integration setup from the config flow init test
* Make `mock_setup_entry` a separate fixture
* Use the mock_config_entry fixture in `test_duplicate_entry`
* `test_duplicate_entry`: Patch `homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list`
* Fix docstring and name for `test_form_invalid_auth` (does not only test auth)
* In `test_form_invalid_auth`, make sure the test run until CREATE_ENTRY to test that the flow is able to recover
config-flow: done
dependency-transparency: done
docs-actions:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
import voluptuous as vol
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
)
from .config_flow import get_model_list
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -67,23 +67,13 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
family = "claude-opus"
suggested_model = "claude-opus-4-5"
elif "haiku" in model:
suggested_model = "claude-haiku-4-5"
elif "sonnet" in model:
family = "claude-sonnet"
suggested_model = "claude-sonnet-4-5"
else:
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
),
vol.UNDEFINED,
)
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
schema = vol.Schema(
{

View File

@@ -33,5 +33,3 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db",
"home-assistant_v2.db-wal",
]
SECURETAR_CREATE_VERSION = 2

View File

@@ -20,9 +20,13 @@ import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
import aiohttp
from securetar import SecureTarArchive, atomic_contents_add
from securetar import SecureTarFile, atomic_contents_add
from homeassistant.backup_restore import RESTORE_BACKUP_FILE, RESTORE_BACKUP_RESULT_FILE
from homeassistant.backup_restore import (
RESTORE_BACKUP_FILE,
RESTORE_BACKUP_RESULT_FILE,
password_to_key,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
@@ -56,7 +60,6 @@ from .const import (
EXCLUDE_DATABASE_FROM_BACKUP,
EXCLUDE_FROM_BACKUP,
LOGGER,
SECURETAR_CREATE_VERSION,
)
from .models import (
AddonInfo,
@@ -1855,22 +1858,20 @@ class CoreBackupReaderWriter(BackupReaderWriter):
return False
with SecureTarArchive(
tar_file_path,
"w",
bufsize=BUF_SIZE,
create_version=SECURETAR_CREATE_VERSION,
password=password,
) as outer_secure_tarfile:
outer_secure_tarfile = SecureTarFile(
tar_file_path, "w", gzip=False, bufsize=BUF_SIZE
)
with outer_secure_tarfile as outer_secure_tarfile_tarfile:
raw_bytes = json_bytes(backup_data)
fileobj = io.BytesIO(raw_bytes)
tar_info = tarfile.TarInfo(name="./backup.json")
tar_info.size = len(raw_bytes)
tar_info.mtime = int(time.time())
outer_secure_tarfile.tar.addfile(tar_info, fileobj=fileobj)
with outer_secure_tarfile.create_tar(
outer_secure_tarfile_tarfile.addfile(tar_info, fileobj=fileobj)
with outer_secure_tarfile.create_inner_tar(
"./homeassistant.tar.gz",
gzip=True,
key=password_to_key(password) if password is not None else None,
) as core_tar:
atomic_contents_add(
tar_file=core_tar,

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
"requirements": ["cronsim==2.7", "securetar==2025.2.1"],
"single_config_entry": true
}

View File

@@ -8,6 +8,7 @@ import copy
from dataclasses import dataclass, replace
from io import BytesIO
import json
import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
@@ -15,15 +16,9 @@ import threading
from typing import IO, Any, cast
import aiohttp
from securetar import (
InvalidPasswordError,
SecureTarArchive,
SecureTarError,
SecureTarFile,
SecureTarReadError,
SecureTarRootKeyContext,
)
from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
@@ -34,7 +29,7 @@ from homeassistant.util.async_iterator import (
)
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
@@ -137,23 +132,17 @@ def suggested_filename(backup: AgentBackup) -> str:
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password.
This assumes every inner tar is encrypted with the same secure tar version and
same password.
"""
with SecureTarArchive(
path, "r", bufsize=BUF_SIZE, password=password
) as backup_file:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
compressed = False
ha_tar_name = "homeassistant.tar"
try:
ha_tar = backup_file.tar.extractfile(ha_tar_name)
ha_tar = backup_file.extractfile(ha_tar_name)
except KeyError:
compressed = True
ha_tar_name = "homeassistant.tar.gz"
try:
ha_tar = backup_file.tar.extractfile(ha_tar_name)
ha_tar = backup_file.extractfile(ha_tar_name)
except KeyError:
LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found")
return False
@@ -161,12 +150,13 @@ def validate_password(path: Path, password: str | None) -> bool:
with SecureTarFile(
path, # Not used
gzip=compressed,
password=password,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=ha_tar,
):
# If we can read the tar file, the password is correct
return True
except tarfile.ReadError, InvalidPasswordError, SecureTarReadError:
except tarfile.ReadError:
LOGGER.debug("Invalid password")
return False
except Exception: # noqa: BLE001
@@ -178,29 +168,27 @@ def validate_password_stream(
input_stream: IO[bytes],
password: str | None,
) -> None:
"""Validate the password.
This assumes every inner tar is encrypted with the same secure tar version and
same password.
"""
with SecureTarArchive(
fileobj=input_stream,
mode="r",
bufsize=BUF_SIZE,
streaming=True,
password=password,
) as input_archive:
for obj in input_archive.tar:
"""Decrypt a backup."""
with (
tarfile.open(fileobj=input_stream, mode="r|", bufsize=BUF_SIZE) as input_tar,
):
for obj in input_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
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if istf.securetar_header.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
@@ -224,25 +212,21 @@ def decrypt_backup(
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
key_context: SecureTarRootKeyContext,
nonces: NonceGenerator,
) -> None:
"""Decrypt a backup."""
error: Exception | None = None
try:
try:
with (
SecureTarArchive(
fileobj=input_stream,
mode="r",
bufsize=BUF_SIZE,
streaming=True,
password=password,
) as input_archive,
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
):
_decrypt_backup(backup, input_archive, output_tar)
_decrypt_backup(backup, input_tar, output_tar, password)
except (DecryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error decrypting backup: %s", err)
error = err
@@ -264,18 +248,19 @@ def decrypt_backup(
def _decrypt_backup(
backup: AgentBackup,
input_archive: SecureTarArchive,
input_tar: tarfile.TarFile,
output_tar: tarfile.TarFile,
password: str | None,
) -> None:
"""Decrypt a backup."""
expected_archives = _get_expected_archives(backup)
for obj in input_archive.tar:
for obj in input_tar:
# We compare with PurePath to avoid issues with different path separators,
# for example when backup.json is added as "./backup.json"
object_path = PurePath(obj.name)
if object_path == PurePath("backup.json"):
# Rewrite the backup.json file to indicate that the backup is decrypted
if not (reader := input_archive.tar.extractfile(obj)):
if not (reader := input_tar.extractfile(obj)):
raise DecryptError
metadata = json_loads_object(reader.read())
metadata["protected"] = False
@@ -287,15 +272,21 @@ def _decrypt_backup(
prefix, _, suffix = object_path.name.partition(".")
if suffix not in ("tar", "tgz", "tar.gz"):
LOGGER.debug("Unknown file %s will not be decrypted", obj.name)
output_tar.addfile(obj, input_archive.tar.extractfile(obj))
output_tar.addfile(obj, input_tar.extractfile(obj))
continue
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be decrypted", obj.name)
output_tar.addfile(obj, input_archive.tar.extractfile(obj))
output_tar.addfile(obj, input_tar.extractfile(obj))
continue
with input_archive.extract_tar(obj) as decrypted:
# Guard against SecureTar v1 which doesn't store plaintext size
if (plaintext_size := decrypted.plaintext_size) is None:
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
)
with istf.decrypt(obj) as decrypted:
if (plaintext_size := istf.securetar_header.plaintext_size) is None:
raise UnsupportedSecureTarVersion
decrypted_obj = copy.deepcopy(obj)
decrypted_obj.size = plaintext_size
@@ -309,7 +300,7 @@ def encrypt_backup(
password: str | None,
on_done: Callable[[Exception | None], None],
minimum_size: int,
key_context: SecureTarRootKeyContext,
nonces: NonceGenerator,
) -> None:
"""Encrypt a backup."""
error: Exception | None = None
@@ -319,16 +310,11 @@ def encrypt_backup(
tarfile.open(
fileobj=input_stream, mode="r|", bufsize=BUF_SIZE
) as input_tar,
SecureTarArchive(
fileobj=output_stream,
mode="w",
bufsize=BUF_SIZE,
streaming=True,
root_key_context=key_context,
create_version=SECURETAR_CREATE_VERSION,
) as output_archive,
tarfile.open(
fileobj=output_stream, mode="w|", bufsize=BUF_SIZE
) as output_tar,
):
_encrypt_backup(backup, input_tar, output_archive)
_encrypt_backup(backup, input_tar, output_tar, password, nonces)
except (EncryptError, SecureTarError, tarfile.TarError) as err:
LOGGER.warning("Error encrypting backup: %s", err)
error = err
@@ -351,7 +337,9 @@ def encrypt_backup(
def _encrypt_backup(
backup: AgentBackup,
input_tar: tarfile.TarFile,
output_archive: SecureTarArchive,
output_tar: tarfile.TarFile,
password: str | None,
nonces: NonceGenerator,
) -> None:
"""Encrypt a backup."""
inner_tar_idx = 0
@@ -369,20 +357,29 @@ def _encrypt_backup(
updated_metadata_b = json.dumps(metadata).encode()
metadata_obj = copy.deepcopy(obj)
metadata_obj.size = len(updated_metadata_b)
output_archive.tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
output_tar.addfile(metadata_obj, BytesIO(updated_metadata_b))
continue
prefix, _, suffix = object_path.name.partition(".")
if suffix not in ("tar", "tgz", "tar.gz"):
LOGGER.debug("Unknown file %s will not be encrypted", obj.name)
output_archive.tar.addfile(obj, input_tar.extractfile(obj))
output_tar.addfile(obj, input_tar.extractfile(obj))
continue
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
output_archive.import_tar(
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
istf = SecureTarFile(
None, # Not used
gzip=False,
key=password_to_key(password) if password is not None else None,
mode="r",
fileobj=input_tar.extractfile(obj),
nonce=nonces.get(inner_tar_idx),
)
inner_tar_idx += 1
with istf.encrypt(obj) as encrypted:
encrypted_obj = copy.deepcopy(obj)
encrypted_obj.size = encrypted.encrypted_size
output_tar.addfile(encrypted_obj, encrypted)
@dataclass(kw_only=True)
@@ -394,6 +391,21 @@ class _CipherWorkerStatus:
writer: AsyncIteratorWriter
class NonceGenerator:
"""Generate nonces for encryption."""
def __init__(self) -> None:
"""Initialize the generator."""
self._nonces: dict[int, bytes] = {}
def get(self, index: int) -> bytes:
"""Get a nonce for the given index."""
if index not in self._nonces:
# Generate a new nonce for the given index
self._nonces[index] = os.urandom(16)
return self._nonces[index]
class _CipherBackupStreamer:
"""Encrypt or decrypt a backup."""
@@ -405,7 +417,7 @@ class _CipherBackupStreamer:
str | None,
Callable[[Exception | None], None],
int,
SecureTarRootKeyContext,
NonceGenerator,
],
None,
]
@@ -423,7 +435,7 @@ class _CipherBackupStreamer:
self._hass = hass
self._open_stream = open_stream
self._password = password
self._key_context = SecureTarRootKeyContext(password)
self._nonces = NonceGenerator()
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
@@ -454,7 +466,7 @@ class _CipherBackupStreamer:
self._password,
on_done,
self.size(),
self._key_context,
self._nonces,
],
)
worker_status = _CipherWorkerStatus(

View File

@@ -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(

View File

@@ -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)

View File

@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
@@ -39,15 +40,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 +70,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 = (
@@ -111,7 +113,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return target_temp.value
@property
def _hvac_mode_value(self) -> int | None:
def _hvac_mode_value(self) -> int | str | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
return None
@@ -122,14 +124,16 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
"""Return hvac operation ie. heat, cool mode."""
if (hvac_mode_value := self._hvac_mode_value) is None:
return None
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
return BSBLAN_TO_HA_HVAC_MODE.get(hvac_mode_value)
return try_parse_enum(HVACMode, hvac_mode_value)
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
if (
action := self.coordinator.data.state.hvac_action
) is None or action.value is None:
action = self.coordinator.data.state.hvac_action
if not action or not isinstance(action.value, int):
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())
@@ -137,7 +141,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 +166,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:

View File

@@ -1,4 +1,4 @@
"""Config flow for BSB-LAN integration."""
"""Config flow for BSB-Lan integration."""
from __future__ import annotations

View File

@@ -1,4 +1,4 @@
"""Constants for the BSB-LAN integration."""
"""Constants for the BSB-Lan integration."""
from __future__ import annotations

View File

@@ -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

View File

@@ -17,24 +17,24 @@ async def async_get_config_entry_diagnostics(
# Build diagnostic data from both coordinators
diagnostics = {
"info": data.info.model_dump(),
"device": data.device.model_dump(),
"info": data.info.to_dict(),
"device": data.device.to_dict(),
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump(),
"state": data.fast_coordinator.data.state.to_dict(),
"sensor": data.fast_coordinator.data.sensor.to_dict(),
"dhw": data.fast_coordinator.data.dhw.to_dict(),
},
"static": data.static.model_dump(),
"static": data.static.to_dict(),
}
# Add DHW config and schedule from slow coordinator if available
if data.slow_coordinator.data:
slow_data = {}
if data.slow_coordinator.data.dhw_config:
slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.model_dump()
slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.to_dict()
if data.slow_coordinator.data.dhw_schedule:
slow_data["dhw_schedule"] = (
data.slow_coordinator.data.dhw_schedule.model_dump()
data.slow_coordinator.data.dhw_schedule.to_dict()
)
if slow_data:
diagnostics["slow_coordinator_data"] = slow_data

View File

@@ -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,

View File

@@ -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

View File

@@ -1,11 +1,4 @@
{
"entity": {
"button": {
"sync_time": {
"default": "mdi:timer-sync-outline"
}
}
},
"services": {
"set_hot_water_schedule": {
"service": "mdi:calendar-clock"

View File

@@ -1,14 +1,13 @@
{
"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"],
"requirements": ["python-bsblan==4.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -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

View File

@@ -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}"

View File

@@ -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,

View File

@@ -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.",

View File

@@ -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
@@ -110,11 +110,12 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if (
operating_mode := self.coordinator.data.dhw.operating_mode
) is None or operating_mode.value is None:
if (operating_mode := self.coordinator.data.dhw.operating_mode) is None:
return None
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
if isinstance(operating_mode.value, int):
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
return None
@property
def current_temperature(self) -> float | None:

View File

@@ -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,
]

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"domain": "dwd_weather_warnings",
"name": "Deutscher Wetterdienst (DWD) Weather Warnings",
"codeowners": ["@runningman84", "@stephan192"],
"codeowners": ["@runningman84", "@stephan192", "@andarotajo"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings",
"integration_type": "service",

View File

@@ -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")

View File

@@ -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",

View File

@@ -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."
},

View File

@@ -28,7 +28,6 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -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))

View File

@@ -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 = (

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.0.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==17.1.0"]
}

View File

@@ -8,24 +8,17 @@ from typing import TYPE_CHECKING, Any
from deebot_client.capabilities import Capabilities, DeviceType
from deebot_client.device import Device
from deebot_client.events import (
CachedMapInfoEvent,
FanSpeedEvent,
RoomsEvent,
StateEvent,
)
from deebot_client.events.map import Map
from deebot_client.models import CleanAction, CleanMode, State
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.models import CleanAction, CleanMode, Room, State
import sucks
from homeassistant.components.vacuum import (
Segment,
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
@@ -36,7 +29,6 @@ from .entity import EcovacsEntity, EcovacsLegacyEntity
from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
_SEGMENTS_SEPARATOR = "_"
ATTR_ERROR = "error"
@@ -226,8 +218,7 @@ class EcovacsVacuum(
"""Initialize the vacuum."""
super().__init__(device, device.capabilities)
self._room_event: RoomsEvent | None = None
self._maps: dict[str, Map] = {}
self._rooms: list[Room] = []
if fan_speed := self._capability.fan_speed:
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
@@ -235,13 +226,14 @@ class EcovacsVacuum(
get_name_key(level) for level in fan_speed.types
]
if self._capability.map and self._capability.clean.action.area:
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_rooms(event: RoomsEvent) -> None:
self._rooms = event.rooms
self.async_write_ha_state()
async def on_status(event: StateEvent) -> None:
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()
@@ -257,20 +249,8 @@ class EcovacsVacuum(
self._subscribe(self._capability.fan_speed.event, on_fan_speed)
if map_caps := self._capability.map:
async def on_rooms(event: RoomsEvent) -> None:
self._room_event = event
self._check_segments_changed()
self.async_write_ha_state()
self._subscribe(map_caps.rooms.event, on_rooms)
async def on_map_info(event: CachedMapInfoEvent) -> None:
self._maps = {map_obj.id: map_obj for map_obj in event.maps}
self._check_segments_changed()
self._subscribe(map_caps.cached_info.event, on_map_info)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes.
@@ -279,10 +259,7 @@ class EcovacsVacuum(
is lowercase snake_case.
"""
rooms: dict[str, Any] = {}
if self._room_event is None:
return rooms
for room in self._room_event.rooms:
for room in self._rooms:
# convert room name to snake_case to meet the convention
room_name = slugify(room.name)
room_values = rooms.get(room_name)
@@ -361,11 +338,11 @@ class EcovacsVacuum(
translation_placeholders={"name": name},
)
if command == "spot_area":
if command in "spot_area":
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.SPOT_AREA,
params["rooms"],
str(params["rooms"]),
params.get("cleanings", 1),
)
)
@@ -373,7 +350,7 @@ class EcovacsVacuum(
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.CUSTOM_AREA,
params["coordinates"],
str(params["coordinates"]),
params.get("cleanings", 1),
)
)
@@ -397,116 +374,3 @@ class EcovacsVacuum(
)
return await self._device.execute_command(position_commands[0])
@callback
def _check_segments_changed(self) -> None:
"""Check if segments have changed and create repair issue."""
last_seen = self.last_seen_segments
if last_seen is None:
return
last_seen_ids = {seg.id for seg in last_seen}
current_ids = {seg.id for seg in self._get_segments()}
if current_ids != last_seen_ids:
self.async_create_segments_issue()
def _get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
last_seen = self.last_seen_segments or []
if self._room_event is None or not self._maps:
# If we don't have the necessary information to determine segments, return the last
# seen segments to avoid temporarily losing all segments until we get the necessary
# information, which could cause unnecessary issues to be created
return last_seen
map_id = self._room_event.map_id
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
return []
id_prefix = f"{map_id}{_SEGMENTS_SEPARATOR}"
other_map_ids = {
map_obj.id
for map_obj in self._maps.values()
if map_obj.id != self._room_event.map_id
}
# Include segments from the current map and any segments from other maps that were
# previously seen, as we want to continue showing segments from other maps for
# mapping purposes
segments = [
seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids
]
segments.extend(
Segment(
id=f"{id_prefix}{room.id}",
name=room.name,
group=map_obj.name,
)
for room in self._room_event.rooms
)
return segments
async def async_get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
return self._get_segments()
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean.
Only cleans segments from the currently selected map.
"""
if not self._maps:
_LOGGER.warning("No map information available, cannot clean segments")
return
valid_room_ids: list[int | float] = []
for composite_id in segment_ids:
map_id, segment_id = _split_composite_id(composite_id)
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
continue
if not map_obj.using:
room_name = next(
(
segment.name
for segment in self.last_seen_segments or []
if segment.id == composite_id
),
"",
)
_LOGGER.warning(
'Map "%s" is not currently selected, skipping segment "%s" (%s)',
map_obj.name,
room_name,
segment_id,
)
continue
valid_room_ids.append(int(segment_id))
if not valid_room_ids:
_LOGGER.warning(
"No valid segments to clean after validation, skipping clean segments command"
)
return
if TYPE_CHECKING:
# Supported feature is only added if clean.action.area is not None
assert self._capability.clean.action.area is not None
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.SPOT_AREA,
valid_room_ids,
1,
)
)
@callback
def _split_composite_id(composite_id: str) -> tuple[str, str]:
"""Split a composite ID into its components."""
map_id, _, segment_id = composite_id.partition(_SEGMENTS_SEPARATOR)
return map_id, segment_id

View File

@@ -13,12 +13,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -64,15 +59,6 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE,
),
EgaugeSensorEntityDescription(
key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
native_value_fn=lambda data, register: data.measurements[register],
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.CURRENT,
),
)

View File

@@ -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)

View File

@@ -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"

View File

@@ -3,19 +3,10 @@
"name": "EnOcean",
"codeowners": [],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "hub",
"integration_type": "device",
"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
}

View File

@@ -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?"
}
}
},

View File

@@ -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

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.1.0",
"aioesphomeapi==44.0.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.6.0"
],

View File

@@ -72,7 +72,7 @@ class FitbitApi(ABC):
configuration = Configuration()
configuration.pool_manager = async_get_clientsession(self._hass)
configuration.access_token = token[CONF_ACCESS_TOKEN]
return await self._hass.async_add_executor_job(ApiClient, configuration)
return ApiClient(configuration)
async def async_get_user_profile(self) -> FitbitProfile:
"""Return the user profile from the API."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==5.0.0"]
"requirements": ["forecast-solar==4.2.0"]
}

View File

@@ -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]:

View File

@@ -63,7 +63,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
host=self.config_entry.data[CONF_HOST],
user=self.config_entry.data[CONF_USERNAME],
password=self.config_entry.data[CONF_PASSWORD],
timeout=20,
)
try:

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.20"],
"requirements": ["pyfritzhome==0.6.19"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -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",

View File

@@ -18,7 +18,7 @@ from yarl import URL
from homeassistant.components import onboarding, websocket_api
from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig
from homeassistant.components.websocket_api import ERR_NOT_FOUND, ActiveConnection
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.config import async_hass_config_yaml
from homeassistant.const import (
CONF_MODE,
@@ -78,16 +78,6 @@ THEMES_STORAGE_VERSION = 1
THEMES_SAVE_DELAY = 60
DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store")
DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes")
PANELS_STORAGE_KEY = f"{DOMAIN}_panels"
PANELS_STORAGE_VERSION = 1
PANELS_SAVE_DELAY = 10
DATA_PANELS_STORE: HassKey[Store[dict[str, dict[str, Any]]]] = HassKey(
"frontend_panels_store"
)
DATA_PANELS_CONFIG: HassKey[dict[str, dict[str, Any]]] = HassKey(
"frontend_panels_config"
)
DATA_DEFAULT_THEME = "frontend_default_theme"
DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme"
DEFAULT_THEME = "default"
@@ -322,11 +312,9 @@ class Panel:
self.sidebar_default_visible = sidebar_default_visible
@callback
def to_response(
self, config_override: dict[str, Any] | None = None
) -> PanelResponse:
def to_response(self) -> PanelResponse:
"""Panel as dictionary."""
response: PanelResponse = {
return {
"component_name": self.component_name,
"icon": self.sidebar_icon,
"title": self.sidebar_title,
@@ -336,18 +324,6 @@ class Panel:
"require_admin": self.require_admin,
"config_panel_domain": self.config_panel_domain,
}
if config_override:
if "require_admin" in config_override:
response["require_admin"] = config_override["require_admin"]
if config_override.get("show_in_sidebar") is False:
response["title"] = None
response["icon"] = None
else:
if "icon" in config_override:
response["icon"] = config_override["icon"]
if "title" in config_override:
response["title"] = config_override["title"]
return response
@bind_hass
@@ -439,24 +415,12 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the serving of the frontend."""
await async_setup_frontend_storage(hass)
panels_store = hass.data[DATA_PANELS_STORE] = Store[dict[str, dict[str, Any]]](
hass, PANELS_STORAGE_VERSION, PANELS_STORAGE_KEY
)
loaded: Any = await panels_store.async_load()
if not isinstance(loaded, dict):
if loaded is not None:
_LOGGER.warning("Ignoring invalid panel storage data")
loaded = {}
hass.data[DATA_PANELS_CONFIG] = loaded
websocket_api.async_register_command(hass, websocket_get_icons)
websocket_api.async_register_command(hass, websocket_get_panels)
websocket_api.async_register_command(hass, websocket_get_themes)
websocket_api.async_register_command(hass, websocket_get_translations)
websocket_api.async_register_command(hass, websocket_get_version)
websocket_api.async_register_command(hass, websocket_subscribe_extra_js)
websocket_api.async_register_command(hass, websocket_update_panel)
hass.http.register_view(ManifestJSONView())
conf = config.get(DOMAIN, {})
@@ -595,7 +559,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
async_register_built_in_panel(hass, "profile")
async_register_built_in_panel(hass, "notfound")
@callback
def async_change_listener(
@@ -920,18 +883,11 @@ def websocket_get_panels(
) -> None:
"""Handle get panels command."""
user_is_admin = connection.user.is_admin
panels_config = hass.data[DATA_PANELS_CONFIG]
panels: dict[str, PanelResponse] = {}
for panel_key, panel in connection.hass.data[DATA_PANELS].items():
config_override = panels_config.get(panel_key)
require_admin = (
config_override.get("require_admin", panel.require_admin)
if config_override
else panel.require_admin
)
if not user_is_admin and require_admin:
continue
panels[panel_key] = panel.to_response(config_override)
panels = {
panel_key: panel.to_response()
for panel_key, panel in connection.hass.data[DATA_PANELS].items()
if user_is_admin or not panel.require_admin
}
connection.send_message(websocket_api.result_message(msg["id"], panels))
@@ -1030,50 +986,6 @@ def websocket_subscribe_extra_js(
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/update_panel",
vol.Required("url_path"): str,
vol.Optional("title"): vol.Any(cv.string, None),
vol.Optional("icon"): vol.Any(cv.icon, None),
vol.Optional("require_admin"): vol.Any(cv.boolean, None),
vol.Optional("show_in_sidebar"): vol.Any(cv.boolean, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_update_panel(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle update panel command."""
url_path: str = msg["url_path"]
if url_path not in hass.data.get(DATA_PANELS, {}):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Panel not found")
return
panels_config = hass.data[DATA_PANELS_CONFIG]
panel_config = dict(panels_config.get(url_path, {}))
for key in ("title", "icon", "require_admin", "show_in_sidebar"):
if key in msg:
if (value := msg[key]) is None:
panel_config.pop(key, None)
else:
panel_config[key] = value
if panel_config:
panels_config[url_path] = panel_config
else:
panels_config.pop(url_path, None)
hass.data[DATA_PANELS_STORE].async_delay_save(
lambda: hass.data[DATA_PANELS_CONFIG], PANELS_SAVE_DELAY
)
hass.bus.async_fire(EVENT_PANELS_UPDATED)
connection.send_result(msg["id"])
class PanelResponse(TypedDict):
"""Represent the panel response type."""

View File

@@ -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
)

View File

@@ -11,10 +11,5 @@
}
}
}
},
"device": {
"google_translate": {
"name": "Google Translate {lang} {tld}"
}
}
}

View File

@@ -19,7 +19,6 @@ from homeassistant.components.tts import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -27,7 +26,6 @@ from .const import (
CONF_TLD,
DEFAULT_LANG,
DEFAULT_TLD,
DOMAIN,
MAP_LANG_TLD,
SUPPORT_LANGUAGES,
SUPPORT_TLD,
@@ -68,9 +66,6 @@ async def async_setup_entry(
class GoogleTTSEntity(TextToSpeechEntity):
"""The Google speech API entity."""
_attr_supported_languages = SUPPORT_LANGUAGES
_attr_supported_options = SUPPORT_OPTIONS
def __init__(self, config_entry: ConfigEntry, lang: str, tld: str) -> None:
"""Init Google TTS service."""
if lang in MAP_LANG_TLD:
@@ -82,15 +77,20 @@ class GoogleTTSEntity(TextToSpeechEntity):
self._attr_name = f"Google Translate {self._lang} {self._tld}"
self._attr_unique_id = config_entry.entry_id
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)},
manufacturer="Google",
model="Google Translate TTS",
translation_key="google_translate",
translation_placeholders={"lang": self._lang, "tld": self._tld},
)
self._attr_default_language = self._lang
@property
def default_language(self) -> str:
"""Return the default language."""
return self._lang
@property
def supported_languages(self) -> list[str]:
"""Return list of supported languages."""
return SUPPORT_LANGUAGES
@property
def supported_options(self) -> list[str]:
"""Return a list of supported options."""
return SUPPORT_OPTIONS
def get_tts_audio(
self, message: str, language: str, options: dict[str, Any] | None = None

View File

@@ -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,

View File

@@ -7,5 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.4.2"]
}

View File

@@ -1,75 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration uses local_push and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: todo
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no configuration parameters.
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -11,12 +11,7 @@ from pyHomee import (
)
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -118,22 +113,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_address")
# If an already configured homee reports with a second IP, abort.
existing_entry = await self.async_set_unique_id(self._name)
if (
existing_entry
and existing_entry.state == ConfigEntryState.LOADED
and existing_entry.runtime_data.connected
and existing_entry.data[CONF_HOST] != self._host
):
_LOGGER.debug(
"Aborting config flow for discovered homee with IP %s "
"since it is already configured at IP %s",
self._host,
existing_entry.data[CONF_HOST],
)
return self.async_abort(reason="2nd_ip_address")
await self.async_set_unique_id(self._name)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
# Cause an auth-error to see if homee is reachable.

View File

@@ -20,7 +20,6 @@ PARALLEL_UPDATES = 0
REMOTE_PROFILES = [
NodeProfile.REMOTE,
NodeProfile.ONE_BUTTON_REMOTE,
NodeProfile.TWO_BUTTON_REMOTE,
NodeProfile.THREE_BUTTON_REMOTE,
NodeProfile.FOUR_BUTTON_REMOTE,

View File

@@ -1,7 +1,6 @@
{
"config": {
"abort": {
"2nd_ip_address": "Your homee is already connected using another IP address",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)",
"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"
}
}
},

View File

@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -1,67 +0,0 @@
"""Shared entity helpers for Homevolt."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltDataUpdateCoordinator
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
"""Base Homevolt entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
) -> None:
"""Initialize the Homevolt entity."""
super().__init__(coordinator)
device_id = coordinator.data.unique_id
device_metadata = coordinator.data.device_metadata.get(device_identifier)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Homevolt calls to handle exceptions."""
async def handler(
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except HomevoltAuthenticationError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from error
except HomevoltConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["homevolt==0.5.0"],
"requirements": ["homevolt==0.4.4"],
"zeroconf": [
{
"name": "homevolt*",

View File

@@ -22,11 +22,13 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity
PARALLEL_UPDATES = 0 # Coordinator-based updates
@@ -307,10 +309,11 @@ async def async_setup_entry(
async_add_entities(entities)
class HomevoltSensor(HomevoltEntity, SensorEntity):
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
"""Representation of a Homevolt sensor."""
entity_description: SensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -319,12 +322,24 @@ class HomevoltSensor(HomevoltEntity, SensorEntity):
sensor_key: str,
) -> None:
"""Initialize the sensor."""
sensor_data = coordinator.data.sensors[sensor_key]
super().__init__(coordinator, sensor_data.device_identifier)
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{sensor_key}"
unique_id = coordinator.data.unique_id
self._attr_unique_id = f"{unique_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
@property
def available(self) -> bool:
"""Return if entity is available."""

View File

@@ -160,22 +160,6 @@
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
},
"exceptions": {
"auth_failed": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"communication_error": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"unknown_error": {
"message": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -1,55 +0,0 @@
"""Support for Homevolt switch entities."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt switch entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
"""Switch entity for Homevolt local mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "local_mode"
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the switch entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def is_on(self) -> bool:
"""Return true if local mode is enabled."""
return self.coordinator.client.local_mode_enabled
@homevolt_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable local mode."""
await self.coordinator.client.enable_local_mode()
await self.coordinator.async_request_refresh()
@homevolt_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable local mode."""
await self.coordinator.client.disable_local_mode()
await self.coordinator.async_request_refresh()

View File

@@ -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:

View File

@@ -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})."
}
}
}

Some files were not shown because too many files have changed in this diff Show More