mirror of
https://github.com/home-assistant/core.git
synced 2026-02-24 19:21:19 +01:00
Compare commits
17 Commits
update-bui
...
matter_cle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d97424a22 | ||
|
|
76114c9ded | ||
|
|
0334dad2f8 | ||
|
|
2dd65172b0 | ||
|
|
4532fd379e | ||
|
|
578b2b3d43 | ||
|
|
9bb2f56fbe | ||
|
|
a7d209f1f5 | ||
|
|
83d73dce5c | ||
|
|
d84f81daf2 | ||
|
|
79d4f5c8cf | ||
|
|
e9e1abb604 | ||
|
|
9a97541253 | ||
|
|
fd39f3c431 | ||
|
|
2cc4a77746 | ||
|
|
d7ef65e562 | ||
|
|
e765c1652c |
20
.github/workflows/builder.yml
vendored
20
.github/workflows/builder.yml
vendored
@@ -272,7 +272,7 @@ jobs:
|
||||
name: Build ${{ matrix.machine }} machine core image
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: ["init", "build_base"]
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
packages: write # To push to GHCR
|
||||
@@ -294,21 +294,6 @@ jobs:
|
||||
- raspberrypi5-64
|
||||
- yellow
|
||||
- green
|
||||
include:
|
||||
# Default: aarch64 on native ARM runner
|
||||
- arch: aarch64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
# Overrides for amd64 machines
|
||||
- machine: generic-x86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
- machine: qemux86-64
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
|
||||
- machine: intel-nuc
|
||||
arch: amd64
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -336,9 +321,8 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build base image
|
||||
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
|
||||
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
|
||||
with:
|
||||
image: ${{ matrix.arch }}
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
--target /data/machine \
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.3"
|
||||
|
||||
@@ -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.*
|
||||
|
||||
14
CODEOWNERS
generated
14
CODEOWNERS
generated
@@ -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,8 +1959,6 @@ 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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -23,7 +23,6 @@ from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""AirOS button component for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
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
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
@@ -2,20 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from airos.discovery import airos_discover_devices
|
||||
from airos.exceptions import (
|
||||
AirOSConnectionAuthenticationError,
|
||||
AirOSConnectionSetupError,
|
||||
AirOSDataMissingError,
|
||||
AirOSDeviceConnectionError,
|
||||
AirOSEndpointError,
|
||||
AirOSKeyDataMissingError,
|
||||
AirOSListenerError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -34,35 +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__)
|
||||
|
||||
# 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(
|
||||
@@ -76,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."""
|
||||
@@ -87,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: 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 = {}
|
||||
@@ -121,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(
|
||||
@@ -257,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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "silver",
|
||||
"requirements": ["airos==0.6.4"]
|
||||
"requirements": ["airos==0.6.3"]
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 doesn’t 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()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/anthropic",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
"requirements": ["anthropic==0.78.0"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -64,6 +64,6 @@ class AtagSensor(AtagEntity, SensorEntity):
|
||||
return self.coordinator.atag.report[self._id].state
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
def icon(self):
|
||||
"""Return icon."""
|
||||
return self.coordinator.atag.report[self._id].icon
|
||||
|
||||
@@ -33,5 +33,3 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
"home-assistant_v2.db",
|
||||
"home-assistant_v2.db-wal",
|
||||
]
|
||||
|
||||
SECURETAR_CREATE_VERSION = 2
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -74,7 +74,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
|
||||
return self._feature.is_on
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
def brightness(self):
|
||||
"""Return the name."""
|
||||
return self._feature.brightness
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -112,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
|
||||
@@ -123,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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==5.0.1"],
|
||||
"requirements": ["python-bsblan==4.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -66,9 +66,6 @@
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
},
|
||||
"total_energy": {
|
||||
"name": "Total energy"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -31,7 +31,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def _convert_image_for_editing(data: bytes) -> tuple[bytes, str]:
|
||||
"""Ensure the image data is in a format accepted by OpenAI image edits."""
|
||||
img: Image.Image
|
||||
stream = io.BytesIO(data)
|
||||
with Image.open(stream) as img:
|
||||
mode = img.mode
|
||||
|
||||
@@ -199,7 +199,7 @@ class Control4Light(Control4Entity, LightEntity):
|
||||
return self.coordinator.data[self._idx][CONTROL4_NON_DIMMER_VAR] > 0
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
if self._is_dimmer:
|
||||
for var in CONTROL4_DIMMER_VARS:
|
||||
|
||||
@@ -132,7 +132,7 @@ class DecoraWifiLight(LightEntity):
|
||||
return self._switch.serial
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self):
|
||||
"""Return the brightness of the dimmer switch."""
|
||||
return int(self._switch.brightness * 255 / 100)
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==12.1.1"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,23 +4,17 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.usb import (
|
||||
human_readable_device_name,
|
||||
usb_unique_id_from_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME
|
||||
from homeassistant.const import CONF_DEVICE
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
|
||||
from . import dongle
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
|
||||
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
|
||||
|
||||
MANUAL_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -37,48 +31,8 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the EnOcean config flow."""
|
||||
self.data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
|
||||
"""Handle usb discovery."""
|
||||
unique_id = usb_unique_id_from_service_info(discovery_info)
|
||||
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_DEVICE: discovery_info.device}
|
||||
)
|
||||
|
||||
discovery_info.device = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, discovery_info.device
|
||||
)
|
||||
|
||||
self.data[CONF_DEVICE] = discovery_info.device
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: human_readable_device_name(
|
||||
discovery_info.device,
|
||||
discovery_info.serial_number,
|
||||
discovery_info.manufacturer,
|
||||
discovery_info.description,
|
||||
discovery_info.vid,
|
||||
discovery_info.pid,
|
||||
)
|
||||
}
|
||||
return await self.async_step_usb_confirm()
|
||||
|
||||
async def async_step_usb_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle USB Discovery confirmation."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]})
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="usb_confirm",
|
||||
description_placeholders={
|
||||
ATTR_MANUFACTURER: MANUFACTURER,
|
||||
CONF_DEVICE: self.data.get(CONF_DEVICE, ""),
|
||||
},
|
||||
)
|
||||
self.dongle_path = None
|
||||
self.discovery_info = None
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Import a yaml configuration."""
|
||||
@@ -150,4 +104,4 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def create_enocean_entry(self, user_input):
|
||||
"""Create an entry for the provided configuration."""
|
||||
return self.async_create_entry(title=MANUFACTURER, data=user_input)
|
||||
return self.async_create_entry(title="EnOcean", data=user_input)
|
||||
|
||||
@@ -6,8 +6,6 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "enocean"
|
||||
|
||||
MANUFACTURER = "EnOcean"
|
||||
|
||||
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"
|
||||
|
||||
SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message"
|
||||
|
||||
@@ -3,19 +3,10 @@
|
||||
"name": "EnOcean",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/enocean",
|
||||
"integration_type": "hub",
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
"device": "[%key:component::enocean::config::step::detect::data_description::device%]"
|
||||
},
|
||||
"description": "Enter the path to your EnOcean USB dongle."
|
||||
},
|
||||
"usb_confirm": {
|
||||
"description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -300,23 +300,16 @@ class RuntimeEntryData:
|
||||
needed_platforms.add(Platform.BINARY_SENSOR)
|
||||
needed_platforms.add(Platform.SELECT)
|
||||
|
||||
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
|
||||
# Make a dict of the EntityInfo by type and send
|
||||
# them to the listeners for each specific EntityInfo type
|
||||
info_types_to_platform = INFO_TYPE_TO_PLATFORM
|
||||
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
|
||||
list
|
||||
)
|
||||
for info in infos:
|
||||
info_type = type(info)
|
||||
if platform := info_types_to_platform.get(info_type):
|
||||
needed_platforms.add(platform)
|
||||
infos_by_type[info_type].append(info)
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Entity type %s is not supported in this version of Home Assistant",
|
||||
info_type,
|
||||
)
|
||||
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
|
||||
infos_by_type[type(info)].append(info)
|
||||
|
||||
for type_, callbacks in self.entity_info_callbacks.items():
|
||||
# If all entities for a type are removed, we
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -75,7 +75,7 @@ class EufyHomeLight(LightEntity):
|
||||
self._attr_is_on = self._bulb.power
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return int(self._brightness * 255 / 100)
|
||||
|
||||
@@ -88,7 +88,7 @@ class EufyHomeLight(LightEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
def hs_color(self):
|
||||
"""Return the color of this light."""
|
||||
return self._hs
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["av==16.0.1", "Pillow==12.1.1"]
|
||||
"requirements": ["av==16.0.1", "Pillow==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -58,12 +58,11 @@ async def async_setup_entry(
|
||||
class GeonetnzVolcanoSensor(SensorEntity):
|
||||
"""Represents an external event with GeoNet NZ Volcano feed data."""
|
||||
|
||||
_attr_icon = DEFAULT_ICON
|
||||
_attr_native_unit_of_measurement = "alert level"
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, config_entry_id, feed_manager, external_id, unit_system):
|
||||
"""Initialize entity with data from feed entry."""
|
||||
self._config_entry_id = config_entry_id
|
||||
self._feed_manager = feed_manager
|
||||
self._external_id = external_id
|
||||
self._attr_unique_id = f"{config_entry_id}_{external_id}"
|
||||
@@ -72,6 +71,8 @@ class GeonetnzVolcanoSensor(SensorEntity):
|
||||
self._distance = None
|
||||
self._latitude = None
|
||||
self._longitude = None
|
||||
self._attribution = None
|
||||
self._alert_level = None
|
||||
self._activity = None
|
||||
self._hazards = None
|
||||
self._feed_last_update = None
|
||||
@@ -123,7 +124,7 @@ class GeonetnzVolcanoSensor(SensorEntity):
|
||||
self._latitude = round(feed_entry.coordinates[0], 5)
|
||||
self._longitude = round(feed_entry.coordinates[1], 5)
|
||||
self._attr_attribution = feed_entry.attribution
|
||||
self._attr_native_value = feed_entry.alert_level
|
||||
self._alert_level = feed_entry.alert_level
|
||||
self._activity = feed_entry.activity
|
||||
self._hazards = feed_entry.hazards
|
||||
self._feed_last_update = dt_util.as_utc(last_update) if last_update else None
|
||||
@@ -132,10 +133,25 @@ class GeonetnzVolcanoSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._alert_level
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return DEFAULT_ICON
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the entity."""
|
||||
return f"Volcano {self._title}"
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return "alert level"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -11,10 +11,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"google_translate": {
|
||||
"name": "Google Translate {lang} {tld}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import DISCOVERY_TIMEOUT, DOMAIN
|
||||
from .const import DISCOVERY_TIMEOUT
|
||||
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
@@ -52,11 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
|
||||
_LOGGER.error("Start failed, errno: %d", ex.errno)
|
||||
return False
|
||||
_LOGGER.error("Port %s already in use", LISTENING_PORT)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="port_in_use",
|
||||
translation_placeholders={"port": LISTENING_PORT},
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -65,9 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
|
||||
while not coordinator.devices:
|
||||
await asyncio.sleep(delay=1)
|
||||
except TimeoutError as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="no_devices_found"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -33,13 +33,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"no_devices_found": {
|
||||
"message": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"port_in_use": {
|
||||
"message": "Port {port} is already in use"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -4,16 +4,16 @@
|
||||
"abort": {
|
||||
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
|
||||
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information.",
|
||||
"not_hassio_thread": "The OpenThread Border Router app can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
|
||||
"otbr_addon_already_running": "The OpenThread Border Router app is already running, it cannot be installed again.",
|
||||
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router app. If you use the Thread network, make sure you have alternative border routers. Uninstall the app and try again.",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
|
||||
"not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please manually set up OpenThread Border Router to communicate with it.",
|
||||
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
|
||||
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
|
||||
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again."
|
||||
},
|
||||
"progress": {
|
||||
"install_firmware": "Installing {firmware_name} firmware.\n\nDo not make any changes to your hardware or software until this finishes.",
|
||||
"install_otbr_addon": "Installing app",
|
||||
"start_otbr_addon": "Starting app"
|
||||
"install_otbr_addon": "Installing add-on",
|
||||
"start_otbr_addon": "Starting add-on"
|
||||
},
|
||||
"step": {
|
||||
"confirm_otbr": {
|
||||
@@ -34,7 +34,7 @@
|
||||
"title": "Updating adapter"
|
||||
},
|
||||
"otbr_failed": {
|
||||
"description": "The OpenThread Border Router app installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other apps, and try again. Check the Supervisor logs if the problem persists.",
|
||||
"description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists.",
|
||||
"title": "Failed to set up OpenThread Border Router"
|
||||
},
|
||||
"pick_firmware": {
|
||||
@@ -89,11 +89,11 @@
|
||||
"silabs_multiprotocol_hardware": {
|
||||
"options": {
|
||||
"abort": {
|
||||
"addon_already_running": "Failed to start the {addon_name} app because it is already running.",
|
||||
"addon_info_failed": "Failed to get {addon_name} app info.",
|
||||
"addon_install_failed": "Failed to install the {addon_name} app.",
|
||||
"addon_already_running": "Failed to start the {addon_name} add-on because it is already running.",
|
||||
"addon_info_failed": "Failed to get {addon_name} add-on info.",
|
||||
"addon_install_failed": "Failed to install the {addon_name} add-on.",
|
||||
"addon_set_config_failed": "Failed to set {addon_name} configuration.",
|
||||
"addon_start_failed": "Failed to start the {addon_name} app.",
|
||||
"addon_start_failed": "Failed to start the {addon_name} add-on.",
|
||||
"not_hassio": "The hardware options can only be configured on Home Assistant OS installations.",
|
||||
"zha_migration_failed": "The ZHA migration did not succeed."
|
||||
},
|
||||
@@ -101,8 +101,8 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
|
||||
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
|
||||
"install_addon": "Please wait while the {addon_name} add-on installation finishes. This can take several minutes.",
|
||||
"start_addon": "Please wait while the {addon_name} add-on start completes. This may take some seconds."
|
||||
},
|
||||
"step": {
|
||||
"addon_installed_other_device": {
|
||||
@@ -129,7 +129,7 @@
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]"
|
||||
},
|
||||
"install_addon": {
|
||||
"title": "The Silicon Labs Multiprotocol app installation has started"
|
||||
"title": "The Silicon Labs Multiprotocol add-on installation has started"
|
||||
},
|
||||
"notify_channel_change": {
|
||||
"description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes.",
|
||||
@@ -143,7 +143,7 @@
|
||||
"title": "Reconfigure IEEE 802.15.4 radio multiprotocol support"
|
||||
},
|
||||
"start_addon": {
|
||||
"title": "The Silicon Labs Multiprotocol app is starting."
|
||||
"title": "The Silicon Labs Multiprotocol add-on is starting."
|
||||
},
|
||||
"uninstall_addon": {
|
||||
"data": {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"read_hw_settings_error": "Failed to read hardware settings",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or app is currently trying to communicate with the device.",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
|
||||
"write_hw_settings_error": "Failed to write hardware settings",
|
||||
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]",
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyhomematic import HMConnection
|
||||
import voluptuous as vol
|
||||
@@ -216,11 +215,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.data[DATA_CONF] = remotes = {}
|
||||
hass.data[DATA_STORE] = set()
|
||||
|
||||
interfaces: dict[str, dict[str, Any]] = conf[CONF_INTERFACES]
|
||||
hosts: dict[str, dict[str, Any]] = conf[CONF_HOSTS]
|
||||
|
||||
# Create hosts-dictionary for pyhomematic
|
||||
for rname, rconfig in interfaces.items():
|
||||
for rname, rconfig in conf[CONF_INTERFACES].items():
|
||||
remotes[rname] = {
|
||||
"ip": rconfig.get(CONF_HOST),
|
||||
"port": rconfig.get(CONF_PORT),
|
||||
@@ -236,7 +232,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"connect": True,
|
||||
}
|
||||
|
||||
for sname, sconfig in hosts.items():
|
||||
for sname, sconfig in conf[CONF_HOSTS].items():
|
||||
remotes[sname] = {
|
||||
"ip": sconfig.get(CONF_HOST),
|
||||
"port": sconfig[CONF_PORT],
|
||||
@@ -262,7 +258,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop)
|
||||
|
||||
# Init homematic hubs
|
||||
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in hosts]
|
||||
entity_hubs = [HMHub(hass, homematic, hub_name) for hub_name in conf[CONF_HOSTS]]
|
||||
|
||||
def _hm_service_virtualkey(service: ServiceCall) -> None:
|
||||
"""Service to handle virtualkey servicecalls."""
|
||||
@@ -298,7 +294,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
def _service_handle_value(service: ServiceCall) -> None:
|
||||
"""Service to call setValue method for HomeMatic system variable."""
|
||||
entity_ids: list[str] | None = service.data.get(ATTR_ENTITY_ID)
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
name = service.data[ATTR_NAME]
|
||||
value = service.data[ATTR_VALUE]
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from pyhomematic import HMConnection
|
||||
from pyhomematic.devicetypes.generic import HMGeneric
|
||||
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.event import track_time_interval
|
||||
@@ -46,16 +45,15 @@ class HMDevice(Entity):
|
||||
entity_description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
"""Initialize a generic HomeMatic device."""
|
||||
self._attr_name = config.get(ATTR_NAME)
|
||||
self._name = config.get(ATTR_NAME)
|
||||
self._address = config.get(ATTR_ADDRESS)
|
||||
self._interface = config.get(ATTR_INTERFACE)
|
||||
self._channel = config.get(ATTR_CHANNEL)
|
||||
self._state = config.get(ATTR_PARAM)
|
||||
if unique_id := config.get(ATTR_UNIQUE_ID):
|
||||
self._attr_unique_id = unique_id.replace(" ", "_")
|
||||
self._unique_id = config.get(ATTR_UNIQUE_ID)
|
||||
self._data: dict[str, Any] = {}
|
||||
self._connected = False
|
||||
self._attr_available = False
|
||||
self._available = False
|
||||
self._channel_map: dict[str, str] = {}
|
||||
|
||||
if entity_description is not None:
|
||||
@@ -69,6 +67,21 @@ class HMDevice(Entity):
|
||||
"""Load data init callbacks."""
|
||||
self._subscribe_homematic_events()
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique ID. HomeMatic entity IDs are unique by default."""
|
||||
return self._unique_id.replace(" ", "_")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if device is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return device specific state attributes."""
|
||||
@@ -103,7 +116,7 @@ class HMDevice(Entity):
|
||||
self._load_data_from_hm()
|
||||
|
||||
# Link events from pyhomematic
|
||||
self._attr_available = not self._hmdevice.UNREACH
|
||||
self._available = not self._hmdevice.UNREACH
|
||||
except Exception as err: # noqa: BLE001
|
||||
self._connected = False
|
||||
_LOGGER.error("Exception while linking %s: %s", self._address, str(err))
|
||||
@@ -119,7 +132,7 @@ class HMDevice(Entity):
|
||||
|
||||
# Availability has changed
|
||||
if self.available != (not self._hmdevice.UNREACH):
|
||||
self._attr_available = not self._hmdevice.UNREACH
|
||||
self._available = not self._hmdevice.UNREACH
|
||||
has_changed = True
|
||||
|
||||
# If it has changed data point, update Home Assistant
|
||||
@@ -200,14 +213,14 @@ class HMHub(Entity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, homematic: HMConnection, name: str) -> None:
|
||||
def __init__(self, hass, homematic, name):
|
||||
"""Initialize HomeMatic hub."""
|
||||
self.hass = hass
|
||||
self.entity_id = f"{DOMAIN}.{name.lower()}"
|
||||
self._homematic = homematic
|
||||
self._variables: dict[str, Any] = {}
|
||||
self._variables = {}
|
||||
self._name = name
|
||||
self._state: int | None = None
|
||||
self._state = None
|
||||
|
||||
# Load data
|
||||
track_time_interval(self.hass, self._update_hub, SCAN_INTERVAL_HUB)
|
||||
@@ -217,12 +230,12 @@ class HMHub(Entity):
|
||||
self.hass.add_job(self._update_variables, None)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self) -> int | None:
|
||||
def state(self):
|
||||
"""Return the state of the entity."""
|
||||
return self._state
|
||||
|
||||
@@ -232,7 +245,7 @@ class HMHub(Entity):
|
||||
return self._variables.copy()
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
return "mdi:gradient-vertical"
|
||||
|
||||
|
||||
@@ -344,4 +344,4 @@ class HMSensor(HMDevice, SensorEntity):
|
||||
if self._state:
|
||||
self._data.update({self._state: None})
|
||||
else:
|
||||
_LOGGER.critical("Unable to initialize sensor: %s", self.name)
|
||||
_LOGGER.critical("Unable to initialize sensor: %s", self._name)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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*",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -6,7 +6,6 @@ from enum import Enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -27,17 +26,6 @@ from .light import get_available_color_modes
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
|
||||
def device_filter(advertisement_data: AdvertisementData) -> bool:
|
||||
"""Return True if the device is supported."""
|
||||
return (
|
||||
SERVICE_UUID in advertisement_data.service_uuids
|
||||
and SERVICE_DATA_UUID in advertisement_data.service_data
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
|
||||
"""Return error if cannot connect and validate."""
|
||||
|
||||
@@ -82,66 +70,28 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
|
||||
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(user_input[CONF_MAC])
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
|
||||
return await self.async_step_confirm()
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in bluetooth.async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not device_filter(discovery.advertisement)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MAC): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name} ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the home assistant scanner."""
|
||||
|
||||
_LOGGER.debug(
|
||||
"HA found light %s. Use user flow to show in UI and connect",
|
||||
"HA found light %s. Will show in UI but not auto connect",
|
||||
discovery_info.name,
|
||||
)
|
||||
return self.async_abort(reason="discovery_unsupported")
|
||||
|
||||
unique_id = dr.format_mac(discovery_info.address)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
name = f"{discovery_info.name} ({discovery_info.address})"
|
||||
self.context.update({"title_placeholders": {CONF_NAME: name}})
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -153,10 +103,7 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
unique_id = dr.format_mac(self._discovery_info.address)
|
||||
# Don't raise on progress because there may be discovery flows
|
||||
await self.async_set_unique_id(unique_id, raise_on_progress=False)
|
||||
# Guard against the user selecting a device which has been configured by
|
||||
# another flow.
|
||||
await self.async_set_unique_id(unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
error = await validate_input(self.hass, unique_id)
|
||||
if error:
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
"not_implemented": "This integration can only be set up via discovery."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -15,16 +14,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"mac": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"mac": "Select the Hue device you want to set up"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["keyrings.alt", "pyicloud"],
|
||||
"requirements": ["pyicloud==2.4.1"]
|
||||
"requirements": ["pyicloud==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -329,14 +329,14 @@ class IDriveE2BackupAgent(BackupAgent):
|
||||
return self._backup_cache
|
||||
|
||||
backups = {}
|
||||
paginator = self._client.get_paginator("list_objects_v2")
|
||||
metadata_files: list[dict[str, Any]] = []
|
||||
async for page in paginator.paginate(Bucket=self._bucket):
|
||||
metadata_files.extend(
|
||||
obj
|
||||
for obj in page.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
)
|
||||
response = await cast(Any, self._client).list_objects_v2(Bucket=self._bucket)
|
||||
|
||||
# Filter for metadata files only
|
||||
metadata_files = [
|
||||
obj
|
||||
for obj in response.get("Contents", [])
|
||||
if obj["Key"].endswith(".metadata.json")
|
||||
]
|
||||
|
||||
for metadata_file in metadata_files:
|
||||
try:
|
||||
|
||||
@@ -68,7 +68,7 @@ class IGloLamp(LightEntity):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return int((self._lamp.state()["brightness"] / 200.0) * 255)
|
||||
|
||||
@@ -97,17 +97,17 @@ class IGloLamp(LightEntity):
|
||||
return self._lamp.min_kelvin
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float]:
|
||||
def hs_color(self):
|
||||
"""Return the hs value."""
|
||||
return color_util.color_RGB_to_hs(*self._lamp.state()["rgb"])
|
||||
|
||||
@property
|
||||
def effect(self) -> str:
|
||||
def effect(self):
|
||||
"""Return the current effect."""
|
||||
return self._lamp.state()["effect"]
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str]:
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects."""
|
||||
return self._lamp.effect_list()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Implementation of the lock platform."""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from igloohome_api import (
|
||||
@@ -64,7 +63,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
|
||||
)
|
||||
self.bridge_id = bridge_id
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
async def async_lock(self, **kwargs):
|
||||
"""Lock this lock."""
|
||||
try:
|
||||
await self.api.create_bridge_proxied_job(
|
||||
@@ -73,7 +72,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
|
||||
except (ApiException, ClientError) as err:
|
||||
raise HomeAssistantError from err
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
async def async_unlock(self, **kwargs):
|
||||
"""Unlock this lock."""
|
||||
try:
|
||||
await self.api.create_bridge_proxied_job(
|
||||
@@ -82,7 +81,7 @@ class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
|
||||
except (ApiException, ClientError) as err:
|
||||
raise HomeAssistantError from err
|
||||
|
||||
async def async_open(self, **kwargs: Any) -> None:
|
||||
async def async_open(self, **kwargs):
|
||||
"""Open (unlatch) this lock."""
|
||||
try:
|
||||
await self.api.create_bridge_proxied_job(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/image_upload",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["Pillow==12.1.1"]
|
||||
"requirements": ["Pillow==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -310,7 +310,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity):
|
||||
return self._config[CONF_HAS_TIME]
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
def icon(self):
|
||||
"""Return the icon to be used for this entity."""
|
||||
return self._config.get(CONF_ICON)
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ class InputNumber(collection.CollectionEntity, RestoreEntity):
|
||||
return self._config.get(CONF_NAME)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
def icon(self):
|
||||
"""Return the icon to be used for this entity."""
|
||||
return self._config.get(CONF_ICON)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity):
|
||||
self._attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
@property
|
||||
def brightness(self) -> int:
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._insteon_device_group.value
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import LOGGER
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT]
|
||||
PLATFORMS = [Platform.FAN]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -27,6 +27,8 @@ class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
|
||||
self._attr_unique_id = device.id
|
||||
|
||||
# Make this HA "device" use the IntelliClima device name.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
|
||||
@@ -62,7 +62,6 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
|
||||
self._attr_unique_id = device.id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
|
||||
@@ -49,7 +49,7 @@ rules:
|
||||
comment: |
|
||||
Unclear if discovery is possible.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Select platform for IntelliClima VMC."""
|
||||
|
||||
from pyintelliclima.const import FanMode, FanSpeed
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaECO
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
from .entity import IntelliClimaECOEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
FAN_MODE_TO_INTELLICLIMA_MODE = {
|
||||
"forward": FanMode.inward,
|
||||
"reverse": FanMode.outward,
|
||||
"alternate": FanMode.alternate,
|
||||
"sensor": FanMode.sensor,
|
||||
}
|
||||
INTELLICLIMA_MODE_TO_FAN_MODE = {v: k for k, v in FAN_MODE_TO_INTELLICLIMA_MODE.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IntelliClimaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up IntelliClima VMC fan mode select."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[IntelliClimaVMCFanModeSelect] = [
|
||||
IntelliClimaVMCFanModeSelect(
|
||||
coordinator=coordinator,
|
||||
device=ecocomfort2,
|
||||
)
|
||||
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
|
||||
"""Representation of an IntelliClima VMC fan mode selector."""
|
||||
|
||||
_attr_translation_key = "fan_mode"
|
||||
_attr_options = ["forward", "reverse", "alternate", "sensor"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self._attr_unique_id = f"{device.id}_fan_mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
device_data = self._device_data
|
||||
|
||||
if device_data.mode_set == FanMode.off:
|
||||
return None
|
||||
|
||||
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
and device_data.mode_set == FanMode.sensor
|
||||
):
|
||||
return None
|
||||
|
||||
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
device_data = self._device_data
|
||||
|
||||
mode = FAN_MODE_TO_INTELLICLIMA_MODE[option]
|
||||
|
||||
# Determine speed: keep current speed if available, otherwise default to sleep
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
or device_data.mode_set == FanMode.off
|
||||
):
|
||||
speed = FanSpeed.sleep
|
||||
else:
|
||||
speed = device_data.speed_set
|
||||
|
||||
await self.coordinator.api.ecocomfort.set_mode_speed(
|
||||
self._device_sn, mode, speed
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -22,18 +22,5 @@
|
||||
"description": "Authenticate against IntelliClima cloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"select": {
|
||||
"fan_mode": {
|
||||
"name": "Fan direction mode",
|
||||
"state": {
|
||||
"alternate": "Alternating",
|
||||
"forward": "Forward",
|
||||
"reverse": "Reverse",
|
||||
"sensor": "Sensor"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +451,7 @@ class AirPlayDevice(MediaPlayerEntity):
|
||||
return self.device_name
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
def icon(self):
|
||||
"""Return the icon to use in the frontend, if any."""
|
||||
if self.selected is True:
|
||||
return "mdi:volume-high"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user