Compare commits

..

10 Commits

Author SHA1 Message Date
Chris Talkington 510ed54b0a Update quality_scale.yaml 2024-12-19 20:24:53 -06:00
Chris Talkington 42c7c1fed4 Update manifest.json 2024-12-19 20:19:13 -06:00
Chris Talkington fce770fedd Update quality_scale.yaml 2024-12-19 20:03:55 -06:00
Chris Talkington 7d10276345 Update quality_scale.yaml 2024-12-19 20:03:55 -06:00
Chris Talkington 99118703c3 Update quality_scale.yaml 2024-12-19 20:03:55 -06:00
Chris Talkington 1569b270c2 Update manifest.json 2024-12-19 20:03:55 -06:00
Chris Talkington 05c063f8d9 Update quality_scale.yaml 2024-12-19 20:03:55 -06:00
Chris Talkington c35bb0596d fill out quality 2024-12-19 20:03:55 -06:00
Chris Talkington 0b3699ce49 Update quality_scale.py 2024-12-19 20:03:55 -06:00
Chris Talkington 8cc55809f9 Add quality scale to roku 2024-12-19 20:03:55 -06:00
528 changed files with 4481 additions and 23688 deletions
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.0
uses: github/codeql-action/init@v3.27.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.0
uses: github/codeql-action/analyze@v3.27.9
with:
category: "/language:python"
-1
View File
@@ -311,7 +311,6 @@ homeassistant.components.manual.*
homeassistant.components.mastodon.*
homeassistant.components.matrix.*
homeassistant.components.matter.*
homeassistant.components.mealie.*
homeassistant.components.media_extractor.*
homeassistant.components.media_player.*
homeassistant.components.media_source.*
+2 -3
View File
@@ -578,8 +578,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
/homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_ble/ @bdraco @PierreAronnax
/tests/components/govee_ble/ @bdraco @PierreAronnax
/homeassistant/components/govee_light_local/ @Galorhallen
/tests/components/govee_light_local/ @Galorhallen
/homeassistant/components/gpsd/ @fabaff @jrieger
@@ -1742,7 +1742,6 @@ build.json @home-assistant/supervisor
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
/tests/components/youtube/ @joostlek
/homeassistant/components/zabbix/ @kruton
/homeassistant/components/zamg/ @killer0071234
/tests/components/zamg/ @killer0071234
/homeassistant/components/zengge/ @emontnemery
-1
View File
@@ -252,7 +252,6 @@ PRELOAD_STORAGE = [
"assist_pipeline.pipelines",
"core.analytics",
"auth_module.totp",
"backup",
]
+1 -15
View File
@@ -1,7 +1,6 @@
"""The AEMET OpenData component."""
import logging
import shutil
from aemet_opendata.exceptions import AemetError, TownNotFound
from aemet_opendata.interface import AEMET, ConnectionOptions, UpdateFeature
@@ -11,9 +10,8 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.storage import STORAGE_DIR
from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DOMAIN, PLATFORMS
from .const import CONF_STATION_UPDATES, PLATFORMS
from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -26,15 +24,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
update_features: int = UpdateFeature.FORECAST
if entry.options.get(CONF_RADAR_UPDATES, False):
update_features |= UpdateFeature.RADAR
if entry.options.get(CONF_STATION_UPDATES, True):
update_features |= UpdateFeature.STATION
options = ConnectionOptions(api_key, update_features)
aemet = AEMET(aiohttp_client.async_get_clientsession(hass), options)
aemet.set_api_data_dir(hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}"))
try:
await aemet.select_coordinates(latitude, longitude)
except TownNotFound as err:
@@ -63,11 +57,3 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
await hass.async_add_executor_job(
shutil.rmtree,
hass.config.path(STORAGE_DIR, f"{DOMAIN}-{entry.unique_id}"),
)
@@ -17,11 +17,10 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler,
)
from .const import CONF_RADAR_UPDATES, CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN
from .const import CONF_STATION_UPDATES, DEFAULT_NAME, DOMAIN
OPTIONS_SCHEMA = vol.Schema(
{
vol.Required(CONF_RADAR_UPDATES, default=False): bool,
vol.Required(CONF_STATION_UPDATES, default=True): bool,
}
)
+1 -2
View File
@@ -51,9 +51,8 @@ from homeassistant.components.weather import (
from homeassistant.const import Platform
ATTRIBUTION = "Powered by AEMET OpenData"
CONF_RADAR_UPDATES = "radar_updates"
CONF_STATION_UPDATES = "station_updates"
PLATFORMS = [Platform.IMAGE, Platform.SENSOR, Platform.WEATHER]
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
DEFAULT_NAME = "AEMET"
DOMAIN = "aemet"
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from aemet_opendata.const import AOD_COORDS, AOD_IMG_BYTES
from aemet_opendata.const import AOD_COORDS
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
@@ -26,7 +26,6 @@ TO_REDACT_CONFIG = [
TO_REDACT_COORD = [
AOD_COORDS,
AOD_IMG_BYTES,
]
-86
View File
@@ -1,86 +0,0 @@
"""Support for the AEMET OpenData images."""
from __future__ import annotations
from typing import Final
from aemet_opendata.const import AOD_DATETIME, AOD_IMG_BYTES, AOD_IMG_TYPE, AOD_RADAR
from aemet_opendata.helpers import dict_nested_value
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
from .entity import AemetEntity
AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = (
ImageEntityDescription(
key=AOD_RADAR,
translation_key="weather_radar",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AemetConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up AEMET OpenData image entities based on a config entry."""
domain_data = config_entry.runtime_data
name = domain_data.name
coordinator = domain_data.coordinator
unique_id = config_entry.unique_id
assert unique_id is not None
async_add_entities(
AemetImage(
hass,
name,
coordinator,
description,
unique_id,
)
for description in AEMET_IMAGES
if dict_nested_value(coordinator.data["lib"], [description.key]) is not None
)
class AemetImage(AemetEntity, ImageEntity):
"""Implementation of an AEMET OpenData image."""
entity_description: ImageEntityDescription
def __init__(
self,
hass: HomeAssistant,
name: str,
coordinator: WeatherUpdateCoordinator,
description: ImageEntityDescription,
unique_id: str,
) -> None:
"""Initialize the image."""
super().__init__(coordinator, name, unique_id)
ImageEntity.__init__(self, hass)
self.entity_description = description
self._attr_unique_id = f"{unique_id}-{description.key}"
self._async_update_attrs()
@callback
def _handle_coordinator_update(self) -> None:
"""Update attributes when the coordinator updates."""
self._async_update_attrs()
super()._handle_coordinator_update()
@callback
def _async_update_attrs(self) -> None:
"""Update image attributes."""
image_data = self.get_aemet_value([self.entity_description.key])
self._cached_image = Image(
content_type=image_data.get(AOD_IMG_TYPE),
content=image_data.get(AOD_IMG_BYTES),
)
self._attr_image_last_updated = image_data.get(AOD_DATETIME)
+1 -1
View File
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aemet",
"iot_class": "cloud_polling",
"loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.6.4"]
"requirements": ["AEMET-OpenData==0.6.3"]
}
@@ -18,18 +18,10 @@
}
}
},
"entity": {
"image": {
"weather_radar": {
"name": "Weather radar"
}
}
},
"options": {
"step": {
"init": {
"data": {
"radar_updates": "Gather data from AEMET weather radar",
"station_updates": "Gather data from AEMET weather stations"
}
}
@@ -11,7 +11,12 @@ from python_homeassistant_analytics import (
from python_homeassistant_analytics.models import IntegrationType
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
@@ -20,7 +25,6 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from . import AnalyticsInsightsConfigEntry
from .const import (
CONF_TRACKED_ADDONS,
CONF_TRACKED_CUSTOM_INTEGRATIONS,
@@ -42,7 +46,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: AnalyticsInsightsConfigEntry,
config_entry: ConfigEntry,
) -> HomeassistantAnalyticsOptionsFlowHandler:
"""Get the options flow for this handler."""
return HomeassistantAnalyticsOptionsFlowHandler()
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],
"requirements": ["python-homeassistant-analytics==0.8.1"],
"requirements": ["python-homeassistant-analytics==0.8.0"],
"single_config_entry": true
}
@@ -1,100 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: todo
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable:
status: done
comment: |
The coordinator handles this.
integration-owner: done
log-when-unavailable:
status: done
comment: |
The coordinator handles this.
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration is a cloud service and thus does not support discovery.
discovery:
status: exempt
comment: |
This integration is a cloud service and thus does not support discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single service.
entity-category: done
entity-device-class:
status: exempt
comment: |
This integration does not have entities with device classes.
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow:
status: exempt
comment: All the options of this integration are managed via the options flow
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: exempt
comment: |
This integration has a fixed single service.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -156,12 +156,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
# and one of them, which could end up being in discovery_info.host, is from a
# different device. If any of the discovery_info.ip_addresses matches the
# existing host, don't update the host.
if (
existing_config_entry
# Ignored entries don't have host
and CONF_HOST in existing_config_entry.data
and len(discovery_info.ip_addresses) > 1
):
if existing_config_entry and len(discovery_info.ip_addresses) > 1:
existing_host = existing_config_entry.data[CONF_HOST]
if existing_host != self.host:
if existing_host in [
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["apprise"],
"quality_scale": "legacy",
"requirements": ["apprise==1.9.1"]
"requirements": ["apprise==1.9.0"]
}
+1 -9
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from aiohttp import ClientConnectorError
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
@@ -47,13 +45,7 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity):
async def async_update(self) -> None:
"""Set the state with the value fetched from the inverter."""
try:
status = await self._api.get_max_power()
except (TimeoutError, ClientConnectorError):
self._attr_available = False
else:
self._attr_available = True
self._attr_native_value = status
self._attr_native_value = await self._api.get_max_power()
async def async_set_native_value(self, value: float) -> None:
"""Set the desired output power."""
@@ -5,10 +5,6 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
# Pre-import backup to avoid it being imported
# later when the import executor is busy and delaying
# startup
from . import backup # noqa: F401
from .agent import (
BackupAgent,
BackupAgentError,
+1 -1
View File
@@ -467,7 +467,7 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
sorted(
backups.items(),
key=lambda backup_item: backup_item[1].date,
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
)[: len(backups) - manager.config.data.retention.copies]
)
await _delete_filtered_backups(manager, _backups_filter)
+2 -15
View File
@@ -48,11 +48,7 @@ from .const import (
)
from .models import AgentBackup, Folder
from .store import BackupStore
from .util import make_backup_dir, read_backup, validate_password
class IncorrectPasswordError(HomeAssistantError):
"""Raised when the password is incorrect."""
from .util import make_backup_dir, read_backup
@dataclass(frozen=True, kw_only=True, slots=True)
@@ -726,10 +722,7 @@ class BackupManager:
"Cannot include all addons and specify specific addons"
)
backup_name = (
name
or f"{"Automatic" if with_automatic_settings else "Custom"} {HAVERSION}"
)
backup_name = name or f"Core {HAVERSION}"
new_backup, self._backup_task = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
backup_name=backup_name,
@@ -1276,12 +1269,6 @@ class CoreBackupReaderWriter(BackupReaderWriter):
remove_after_restore = True
password_valid = await self._hass.async_add_executor_job(
validate_password, path, password
)
if not password_valid:
raise IncorrectPasswordError("The password provided is incorrect.")
def _write_restore_file() -> None:
"""Write the restore file."""
Path(self._hass.config.path(RESTORE_BACKUP_FILE)).write_text(
+2 -38
View File
@@ -9,13 +9,11 @@ import tarfile
from typing import cast
import aiohttp
from securetar import SecureTarFile
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.util.json import JsonObjectType, json_loads_object
from .const import BUF_SIZE, LOGGER
from .const import BUF_SIZE
from .models import AddonInfo, AgentBackup, Folder
@@ -52,7 +50,6 @@ def read_backup(backup_path: Path) -> AgentBackup:
if (
homeassistant := cast(JsonObjectType, data.get("homeassistant"))
) and "version" in homeassistant:
homeassistant_included = True
homeassistant_version = cast(str, homeassistant["version"])
database_included = not cast(
bool, homeassistant.get("exclude_database", False)
@@ -63,7 +60,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
backup_id=cast(str, data["slug"]),
database_included=database_included,
date=cast(str, data["date"]),
extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
extra_metadata=cast(dict[str, bool | str], data.get("metadata", {})),
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
@@ -73,39 +70,6 @@ def read_backup(backup_path: Path) -> AgentBackup:
)
def validate_password(path: Path, password: str | None) -> bool:
"""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.extractfile(ha_tar_name)
except KeyError:
compressed = True
ha_tar_name = "homeassistant.tar.gz"
try:
ha_tar = backup_file.extractfile(ha_tar_name)
except KeyError:
LOGGER.error("No homeassistant.tar or homeassistant.tar.gz found")
return False
try:
with SecureTarFile(
path, # Not used
gzip=compressed,
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:
LOGGER.debug("Invalid password")
return False
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error validating password")
return False
async def receive_file(
hass: HomeAssistant, contents: aiohttp.BodyPartReader, path: Path
) -> None:
+14 -22
View File
@@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback
from .config import ScheduleState
from .const import DATA_MANAGER, LOGGER
from .manager import IncorrectPasswordError, ManagerStateEvent
from .manager import ManagerStateEvent
from .models import Folder
@@ -131,20 +131,16 @@ async def handle_restore(
msg: dict[str, Any],
) -> None:
"""Restore a backup."""
try:
await hass.data[DATA_MANAGER].async_restore_backup(
msg["backup_id"],
agent_id=msg["agent_id"],
password=msg.get("password"),
restore_addons=msg.get("restore_addons"),
restore_database=msg["restore_database"],
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else:
connection.send_result(msg["id"])
await hass.data[DATA_MANAGER].async_restore_backup(
msg["backup_id"],
agent_id=msg["agent_id"],
password=msg.get("password"),
restore_addons=msg.get("restore_addons"),
restore_database=msg["restore_database"],
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
connection.send_result(msg["id"])
@websocket_api.require_admin
@@ -295,15 +291,11 @@ async def handle_config_info(
vol.Required("type"): "backup/config/update",
vol.Optional("create_backup"): vol.Schema(
{
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
vol.Optional("include_addons"): vol.Any(
vol.All([str], vol.Unique()), None
),
vol.Optional("agent_ids"): vol.All(list[str]),
vol.Optional("include_addons"): vol.Any(list[str], None),
vol.Optional("include_all_addons"): bool,
vol.Optional("include_database"): bool,
vol.Optional("include_folders"): vol.Any(
vol.All([vol.Coerce(Folder)], vol.Unique()), None
),
vol.Optional("include_folders"): vol.Any([vol.Coerce(Folder)], None),
vol.Optional("name"): vol.Any(str, None),
vol.Optional("password"): vol.Any(str, None),
},
+5 -5
View File
@@ -84,16 +84,16 @@
}
},
"send_pin": {
"name": "Send PIN",
"description": "Sends a new PIN to Blink for 2FA.",
"name": "Send pin",
"description": "Sends a new PIN to blink for 2FA.",
"fields": {
"pin": {
"name": "PIN",
"description": "PIN received from Blink. Leave empty if you only received a verification email."
"name": "Pin",
"description": "PIN received from blink. Leave empty if you only received a verification email."
},
"config_entry_id": {
"name": "Integration ID",
"description": "The Blink Integration ID."
"description": "The Blink Integration id."
}
}
}
@@ -103,10 +103,9 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self._existing_entry_data: dict[str, Any] = {}
data: dict[str, Any] = {}
_existing_entry_data: Mapping[str, Any] | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -176,15 +175,19 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show the change password step."""
existing_data = (
dict(self._existing_entry_data) if self._existing_entry_data else {}
)
if user_input is not None:
return await self.async_step_user(self._existing_entry_data | user_input)
return await self.async_step_user(existing_data | user_input)
return self.async_show_form(
step_id="change_password",
data_schema=RECONFIGURE_SCHEMA,
description_placeholders={
CONF_USERNAME: self._existing_entry_data[CONF_USERNAME],
CONF_REGION: self._existing_entry_data[CONF_REGION],
CONF_USERNAME: existing_data[CONF_USERNAME],
CONF_REGION: existing_data[CONF_REGION],
},
)
@@ -192,14 +195,14 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle configuration by re-auth."""
self._existing_entry_data = dict(entry_data)
self._existing_entry_data = entry_data
return await self.async_step_change_password()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
self._existing_entry_data = dict(self._get_reconfigure_entry().data)
self._existing_entry_data = self._get_reconfigure_entry().data
return await self.async_step_change_password()
async def async_step_captcha(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
"requirements": ["caldav==1.3.9", "icalendar==6.1.0"]
"requirements": ["caldav==1.3.9"]
}
@@ -1,85 +0,0 @@
"""Support for media browsing."""
from aiostreammagic import StreamMagicClient
from aiostreammagic.models import Preset
from homeassistant.components.media_player import BrowseMedia, MediaClass
from homeassistant.core import HomeAssistant
async def async_browse_media(
hass: HomeAssistant,
client: StreamMagicClient,
media_content_id: str | None,
media_content_type: str | None,
) -> BrowseMedia:
"""Browse media."""
if media_content_type == "presets":
return await _presets_payload(client.preset_list.presets)
return await _root_payload(
hass,
client,
)
async def _root_payload(
hass: HomeAssistant,
client: StreamMagicClient,
) -> BrowseMedia:
"""Return root payload for Cambridge Audio."""
children: list[BrowseMedia] = []
if client.preset_list.presets:
children.append(
BrowseMedia(
title="Presets",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png",
can_play=False,
can_expand=True,
)
)
return BrowseMedia(
title="Cambridge Audio",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="root",
can_play=False,
can_expand=True,
children=children,
)
async def _presets_payload(presets: list[Preset]) -> BrowseMedia:
"""Create payload to list presets."""
children: list[BrowseMedia] = []
for preset in presets:
if preset.state != "OK":
continue
children.append(
BrowseMedia(
title=preset.name,
media_class=MediaClass.MUSIC,
media_content_id=str(preset.preset_id),
media_content_type="preset",
can_play=True,
can_expand=False,
thumbnail=preset.art_url,
)
)
return BrowseMedia(
title="Presets",
media_class=MediaClass.DIRECTORY,
media_content_id="",
media_content_type="presets",
can_play=False,
can_expand=True,
children=children,
)
@@ -13,7 +13,6 @@ from aiostreammagic import (
)
from homeassistant.components.media_player import (
BrowseMedia,
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -25,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import CambridgeAudioConfigEntry, media_browser
from . import CambridgeAudioConfigEntry
from .const import (
CAMBRIDGE_MEDIA_TYPE_AIRABLE,
CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO,
@@ -35,8 +34,7 @@ from .const import (
from .entity import CambridgeAudioEntity, command
BASE_FEATURES = (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.SELECT_SOURCE
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -340,13 +338,3 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
if media_type == CAMBRIDGE_MEDIA_TYPE_INTERNET_RADIO:
await self.client.play_radio_url("Radio", media_id)
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the media browsing helper."""
return await media_browser.async_browse_media(
self.hass, self.client, media_content_id, media_content_type
)
+1 -8
View File
@@ -36,14 +36,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.signal_type import SignalType
# Pre-import backup to avoid it being imported
# later when the import executor is busy and delaying
# startup
from . import (
account_link,
backup, # noqa: F401
http_api,
)
from . import account_link, http_api
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
+2 -28
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
from collections.abc import AsyncIterator, Callable, Coroutine
import hashlib
from typing import Any, Self
@@ -18,10 +18,9 @@ from hass_nabucasa.cloud_api import (
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
from .const import DATA_CLOUD, DOMAIN
_STORAGE_BACKUP = "backup"
@@ -46,31 +45,6 @@ async def async_get_backup_agents(
return [CloudBackupAgent(hass=hass, cloud=cloud)]
@callback
def async_register_backup_agents_listener(
hass: HomeAssistant,
*,
listener: Callable[[], None],
**kwargs: Any,
) -> Callable[[], None]:
"""Register a listener to be called when agents are added or removed."""
@callback
def unsub() -> None:
"""Unsubscribe from events."""
unsub_signal()
@callback
def handle_event(data: Mapping[str, Any]) -> None:
"""Handle event."""
if data["type"] not in ("login", "logout"):
return
listener()
unsub_signal = async_dispatcher_connect(hass, EVENT_CLOUD_EVENT, handle_event)
return unsub
class ChunkAsyncStreamIterator:
"""Async iterator for chunked streams.
-2
View File
@@ -18,8 +18,6 @@ DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
"cloud_platforms_setup"
)
EVENT_CLOUD_EVENT = "cloud_event"
REQUEST_TIMEOUT = 10
PREF_ENABLE_ALEXA = "alexa_enabled"
@@ -34,7 +34,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.location import async_detect_location_info
from .alexa_config import entity_supported as entity_supported_by_alexa
@@ -42,7 +41,6 @@ from .assist_pipeline import async_create_cloud_pipeline
from .client import CloudClient
from .const import (
DATA_CLOUD,
EVENT_CLOUD_EVENT,
LOGIN_MFA_TIMEOUT,
PREF_ALEXA_REPORT_STATE,
PREF_DISABLE_2FA,
@@ -280,8 +278,6 @@ class CloudLoginView(HomeAssistantView):
new_cloud_pipeline_id = await async_create_cloud_pipeline(hass)
else:
new_cloud_pipeline_id = None
async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "login"})
return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id})
@@ -301,7 +297,6 @@ class CloudLogoutView(HomeAssistantView):
async with asyncio.timeout(REQUEST_TIMEOUT):
await cloud.logout()
async_dispatcher_send(hass, EVENT_CLOUD_EVENT, {"type": "logout"})
return self.json_message("ok")
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.20"]
"requirements": ["hassil==2.0.5", "home-assistant-intents==2024.12.9"]
}
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.42.0", "getmac==0.9.4"],
"requirements": ["async-upnp-client==0.41.0", "getmac==0.9.4"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.42.0"],
"requirements": ["async-upnp-client==0.41.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
+2 -48
View File
@@ -2,12 +2,7 @@
from dataclasses import dataclass
from deebot_client.capabilities import (
CapabilityExecute,
CapabilityExecuteTypes,
CapabilityLifeSpan,
)
from deebot_client.commands import StationAction
from deebot_client.capabilities import CapabilityExecute, CapabilityLifeSpan
from deebot_client.events import LifeSpan
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
@@ -16,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EcovacsConfigEntry
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
from .const import SUPPORTED_LIFESPANS
from .entity import (
EcovacsCapabilityEntityDescription,
EcovacsDescriptionEntity,
@@ -40,13 +35,6 @@ class EcovacsLifespanButtonEntityDescription(ButtonEntityDescription):
component: LifeSpan
@dataclass(kw_only=True, frozen=True)
class EcovacsStationActionButtonEntityDescription(ButtonEntityDescription):
"""Ecovacs station action button entity description."""
action: StationAction
ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = (
EcovacsButtonEntityDescription(
capability_fn=lambda caps: caps.map.relocation if caps.map else None,
@@ -56,16 +44,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsButtonEntityDescription, ...] = (
),
)
STATION_ENTITY_DESCRIPTIONS = tuple(
EcovacsStationActionButtonEntityDescription(
action=action,
key=f"station_action_{action.name.lower()}",
translation_key=f"station_action_{action.name.lower()}",
)
for action in SUPPORTED_STATION_ACTIONS
)
LIFESPAN_ENTITY_DESCRIPTIONS = tuple(
EcovacsLifespanButtonEntityDescription(
component=component,
@@ -96,15 +74,6 @@ async def async_setup_entry(
for description in LIFESPAN_ENTITY_DESCRIPTIONS
if description.component in device.capabilities.life_span.types
)
entities.extend(
EcovacsStationActionButtonEntity(
device, device.capabilities.station.action, description
)
for device in controller.devices
if device.capabilities.station
for description in STATION_ENTITY_DESCRIPTIONS
if description.action in device.capabilities.station.action.types
)
async_add_entities(entities)
@@ -134,18 +103,3 @@ class EcovacsResetLifespanButtonEntity(
await self._device.execute_command(
self._capability.reset(self.entity_description.component)
)
class EcovacsStationActionButtonEntity(
EcovacsDescriptionEntity[CapabilityExecuteTypes[StationAction]],
ButtonEntity,
):
"""Ecovacs station action button entity."""
entity_description: EcovacsStationActionButtonEntityDescription
async def async_press(self) -> None:
"""Press the button."""
await self._device.execute_command(
self._capability.execute(self.entity_description.action)
)
@@ -2,7 +2,6 @@
from enum import StrEnum
from deebot_client.commands import StationAction
from deebot_client.events import LifeSpan
DOMAIN = "ecovacs"
@@ -20,11 +19,8 @@ SUPPORTED_LIFESPANS = (
LifeSpan.SIDE_BRUSH,
LifeSpan.UNIT_CARE,
LifeSpan.ROUND_MOP,
LifeSpan.STATION_FILTER,
)
SUPPORTED_STATION_ACTIONS = (StationAction.EMPTY_DUSTBIN,)
LEGACY_SUPPORTED_LIFESPANS = (
"main_brush",
"side_brush",
@@ -27,17 +27,11 @@
"reset_lifespan_side_brush": {
"default": "mdi:broom"
},
"reset_lifespan_station_filter": {
"default": "mdi:air-filter"
},
"reset_lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
"reset_lifespan_round_mop": {
"default": "mdi:broom"
},
"station_action_empty_dustbin": {
"default": "mdi:delete-restore"
}
},
"event": {
@@ -78,9 +72,6 @@
"lifespan_side_brush": {
"default": "mdi:broom"
},
"lifespan_station_filter": {
"default": "mdi:air-filter"
},
"lifespan_unit_care": {
"default": "mdi:robot-vacuum"
},
@@ -96,9 +87,6 @@
"network_ssid": {
"default": "mdi:wifi"
},
"station_state": {
"default": "mdi:home"
},
"stats_area": {
"default": "mdi:floor-plan"
},
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==10.0.1"]
"requirements": ["py-sucks==0.9.10", "deebot-client==9.4.0"]
}
+1 -1
View File
@@ -95,7 +95,7 @@ async def async_setup_entry(
class EcovacsNumberEntity(
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
EcovacsDescriptionEntity[CapabilitySet[EventT, int]],
NumberEntity,
):
"""Ecovacs number entity."""
+2 -2
View File
@@ -66,7 +66,7 @@ async def async_setup_entry(
class EcovacsSelectEntity(
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]],
EcovacsDescriptionEntity[CapabilitySetTypes[EventT, str]],
SelectEntity,
):
"""Ecovacs select entity."""
@@ -77,7 +77,7 @@ class EcovacsSelectEntity(
def __init__(
self,
device: Device,
capability: CapabilitySetTypes[EventT, [str], str],
capability: CapabilitySetTypes[EventT, str],
entity_description: EcovacsSelectEntityDescription,
**kwargs: Any,
) -> None:
+1 -11
View File
@@ -16,7 +16,6 @@ from deebot_client.events import (
NetworkInfoEvent,
StatsEvent,
TotalStatsEvent,
station,
)
from sucks import VacBot
@@ -47,7 +46,7 @@ from .entity import (
EcovacsLegacyEntity,
EventT,
)
from .util import get_name_key, get_options, get_supported_entitites
from .util import get_supported_entitites
@dataclass(kw_only=True, frozen=True)
@@ -137,15 +136,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
),
# Station
EcovacsSensorEntityDescription[station.StationEvent](
capability_fn=lambda caps: caps.station.state if caps.station else None,
value_fn=lambda e: get_name_key(e.state),
key="station_state",
translation_key="station_state",
device_class=SensorDeviceClass.ENUM,
options=get_options(station.State),
),
)
@@ -46,9 +46,6 @@
"relocate": {
"name": "Relocate"
},
"reset_lifespan_base_station_filter": {
"name": "Reset station filter lifespan"
},
"reset_lifespan_blade": {
"name": "Reset blade lifespan"
},
@@ -69,9 +66,6 @@
},
"reset_lifespan_side_brush": {
"name": "Reset side brush lifespan"
},
"station_action_empty_dustbin": {
"name": "Empty dustbin"
}
},
"event": {
@@ -113,9 +107,6 @@
}
}
},
"lifespan_base_station_filter": {
"name": "Station filter lifespan"
},
"lifespan_blade": {
"name": "Blade lifespan"
},
@@ -149,13 +140,6 @@
"network_ssid": {
"name": "Wi-Fi SSID"
},
"station_state": {
"name": "Station state",
"state": {
"idle": "[%key:common::state::idle%]",
"emptying_dustbin": "Emptying dustbin"
}
},
"stats_area": {
"name": "Area cleaned"
},
+1 -1
View File
@@ -131,7 +131,7 @@ class EcovacsSwitchEntity(
await super().async_added_to_hass()
async def on_event(event: EnableEvent) -> None:
self._attr_is_on = event.enabled
self._attr_is_on = event.enable
self.async_write_ha_state()
self._subscribe(self._capability.event, on_event)
-11
View File
@@ -7,8 +7,6 @@ import random
import string
from typing import TYPE_CHECKING
from deebot_client.events.station import State
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify
@@ -49,13 +47,4 @@ def get_supported_entitites(
@callback
def get_name_key(enum: Enum) -> str:
"""Return the lower case name of the enum."""
if enum is State.EMPTYING:
# Will be fixed in the next major release of deebot-client
return "emptying_dustbin"
return enum.name.lower()
@callback
def get_options(enum: type[Enum]) -> list[str]:
"""Return the options for the enum."""
return [get_name_key(option) for option in enum]
@@ -6,16 +6,11 @@ from dataclasses import dataclass
from elevenlabs import AsyncElevenLabs, Model
from elevenlabs.core import ApiError
from httpx import ConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MODEL
@@ -40,10 +35,10 @@ class ElevenLabsData:
model: Model
type ElevenLabsConfigEntry = ConfigEntry[ElevenLabsData]
type EleventLabsConfigEntry = ConfigEntry[ElevenLabsData]
async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: EleventLabsConfigEntry) -> bool:
"""Set up ElevenLabs text-to-speech from a config entry."""
entry.add_update_listener(update_listener)
httpx_client = get_async_client(hass)
@@ -53,8 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -
model_id = entry.options[CONF_MODEL]
try:
model = await get_model_by_id(client, model_id)
except ConnectError as err:
raise ConfigEntryNotReady("Failed to connect") from err
except ApiError as err:
raise ConfigEntryAuthFailed("Auth failed") from err
@@ -67,13 +60,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -
return True
async def async_unload_entry(hass: HomeAssistant, entry: ElevenLabsConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: EleventLabsConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(
hass: HomeAssistant, config_entry: ElevenLabsConfigEntry
hass: HomeAssistant, config_entry: EleventLabsConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
from . import ElevenLabsConfigEntry
from . import EleventLabsConfigEntry
from .const import (
CONF_CONFIGURE_VOICE,
CONF_MODEL,
@@ -92,7 +92,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
def async_get_options_flow(
config_entry: ElevenLabsConfigEntry,
config_entry: EleventLabsConfigEntry,
) -> OptionsFlow:
"""Create the options flow."""
return ElevenLabsOptionsFlow(config_entry)
@@ -101,7 +101,7 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
class ElevenLabsOptionsFlow(OptionsFlow):
"""ElevenLabs options flow."""
def __init__(self, config_entry: ElevenLabsConfigEntry) -> None:
def __init__(self, config_entry: EleventLabsConfigEntry) -> None:
"""Initialize options flow."""
self.api_key: str = config_entry.data[CONF_API_KEY]
# id -> name
@@ -7,7 +7,11 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow-test-coverage:
status: todo
comment: >
We should have every test end in either ABORT or CREATE_ENTRY.
test_invalid_api_key should assert the kind of error that is raised.
config-flow: done
dependency-transparency: done
docs-actions: done
+2 -2
View File
@@ -22,7 +22,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ElevenLabsConfigEntry
from . import EleventLabsConfigEntry
from .const import (
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
@@ -56,7 +56,7 @@ def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElevenLabsConfigEntry,
config_entry: EleventLabsConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up ElevenLabs tts platform via config entry."""
@@ -151,9 +151,7 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
port=self._panel_direct_port,
)
)
ssl_context = await self.hass.async_add_executor_job(
build_direct_ssl_context, self._panel_direct_ssl_cert
)
ssl_context = build_direct_ssl_context(cadata=self._panel_direct_ssl_cert)
# Attempt the connection to make sure the pin works. Also, take the chance to retrieve the panel ID via APIs.
client_api_url = get_direct_api_url(
+1 -1
View File
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/elmax",
"iot_class": "cloud_polling",
"loggers": ["elmax_api"],
"requirements": ["elmax-api==0.0.6.4rc0"],
"requirements": ["elmax-api==0.0.6.3"],
"zeroconf": [
{
"type": "_elmax-ssl._tcp.local."
+2 -2
View File
@@ -50,7 +50,7 @@
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"panel_pin": "Panel PIN"
"panel_pin": "Panel Pin"
}
}
},
@@ -58,7 +58,7 @@
"no_panel_online": "No online Elmax control panel was found.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"network_error": "A network error occurred",
"invalid_pin": "The provided PIN is invalid",
"invalid_pin": "The provided pin is invalid",
"invalid_mode": "Invalid or unsupported mode",
"reauth_panel_disappeared": "The given panel is no longer associated to this user. Please log in using an account associated to this panel.",
"unknown": "[%key:common::config_flow::error::unknown%]"
@@ -77,7 +77,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = entry.runtime_data
coordinator: EnphaseUpdateCoordinator = entry.runtime_data
coordinator.async_cancel_token_refresh()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -141,13 +141,9 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
and entry.data[CONF_HOST] == self.ip_address
):
_LOGGER.debug(
"Zeroconf update envoy with this ip and blank unique_id",
"Zeroconf update envoy with this ip and blank serial in unique_id",
)
# Found an entry with blank unique_id (prior deleted) with same ip
# If the title is still default shorthand 'Envoy' then append serial
# to differentiate multiple Envoy. Don't change the title if any other
# title is still present in the old entry.
title = f"{ENVOY} {serial}" if entry.title == ENVOY else entry.title
title = f"{ENVOY} {serial}" if entry.title == ENVOY else ENVOY
return self.async_update_reload_and_abort(
entry, title=title, unique_id=serial, reason="already_configured"
)
@@ -37,7 +37,6 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
envoy_serial_number: str
envoy_firmware: str
config_entry: EnphaseConfigEntry
def __init__(
self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry
@@ -45,6 +44,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Initialize DataUpdateCoordinator for the envoy."""
self.envoy = envoy
entry_data = entry.data
self.entry = entry
self.username = entry_data[CONF_USERNAME]
self.password = entry_data[CONF_PASSWORD]
self._setup_complete = False
@@ -107,7 +107,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
await envoy.setup()
assert envoy.serial_number is not None
self.envoy_serial_number = envoy.serial_number
if token := self.config_entry.data.get(CONF_TOKEN):
if token := self.entry.data.get(CONF_TOKEN):
with contextlib.suppress(*INVALID_AUTH_ERRORS):
# Always set the username and password
# so we can refresh the token if needed
@@ -136,9 +136,9 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# as long as the token is valid
_LOGGER.debug("%s: Updating token in config entry from auth", self.name)
self.hass.config_entries.async_update_entry(
self.config_entry,
self.entry,
data={
**self.config_entry.data,
**self.entry.data,
CONF_TOKEN: envoy.auth.token,
},
)
@@ -189,7 +189,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
# reload the integration to get all established again
self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
self.hass.config_entries.async_reload(self.entry.entry_id)
)
# remember firmware version for next time
self.envoy_firmware = envoy.firmware
@@ -7,16 +7,29 @@ rules:
status: done
comment: fixed 1 minute cycle based on Enphase Envoy device characteristics
brands: done
common-modules: done
common-modules:
status: done
comment: |
In coordinator.py, you set self.entry = entry, while after the super constructor,
you can access the entry via self.config_entry (you would have to overwrite the
type to make sure you don't have to assert not None every time)done
config-flow-test-coverage:
status: todo
comment: |
- test_form is missing an assertion for the unique id of the resulting entry
- Let's also have test_user_no_serial_number assert the unique_id (as in, it can't be set to the serial_number since we dont have one, so let's assert what it will result in)
- Let's have every test result in either CREATE_ENTRY or ABORT (like test_form_invalid_auth or test_form_cannot_connect, they can be parametrized)
- test_zeroconf_token_firmware and test_zeroconf_pre_token_firmware can also be parametrized I think
- test_zero_conf_malformed_serial_property - with pytest.raises(KeyError) as ex::
I don't believe this should be able to raise a KeyError Shouldn't we abort the flow?
test_reauth -> Let's also assert result before we start with the async_configure part
config-flow:
status: todo
comment: |
- async_step_zeroconf -> a config entry title is considered userland,
so if someone renamed their entry, it will be reverted back with the code at L146.
- async_step_reaut L160: I believe that the unique is already set when starting a reauth flow
- The config flow is missing data descriptions for the other fields
dependency-transparency: done
docs-actions:
status: done
@@ -35,7 +48,11 @@ rules:
comment: no events used.
entity-unique-id: done
has-entity-name: done
runtime-data: done
runtime-data:
status: done
comment: |
async_unload_entry- coordinator: EnphaseUpdateCoordinator = entry.runtime_data
You can remove the EnphaseUpdateCoordinator as the type can now be inferred thanks to the typed config entry
test-before-configure: done
test-before-setup: done
unique-config-entry: done
@@ -230,8 +230,6 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
@esphome_float_state_property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if not self._static_info.supports_current_temperature:
return None
return self._state.current_temperature
@property
@@ -14,7 +14,6 @@ import feedparser
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -102,11 +101,7 @@ class FeedReaderCoordinator(
async def async_setup(self) -> None:
"""Set up the feed manager."""
try:
feed = await self._async_fetch_feed()
except UpdateFailed as err:
raise ConfigEntryNotReady from err
feed = await self._async_fetch_feed()
self.logger.debug("Feed data fetched from %s : %s", self.url, feed["feed"])
if feed_author := feed["feed"].get("author"):
self.feed_author = html.unescape(feed_author)
@@ -1,5 +1,4 @@
{
"title": "Filter",
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
@@ -23,9 +23,6 @@ from . import FlexitCoordinator
from .const import DOMAIN
from .entity import FlexitEntity
_MAX_FAN_SETPOINT = 100
_MIN_FAN_SETPOINT = 30
@dataclass(kw_only=True, frozen=True)
class FlexitNumberEntityDescription(NumberEntityDescription):
@@ -37,24 +34,6 @@ class FlexitNumberEntityDescription(NumberEntityDescription):
set_native_value_fn: Callable[[FlexitBACnet], Callable[[int], Awaitable[None]]]
# Setpoints for Away, Home and High are dependent of each other. Fireplace and Cooker Hood
# have setpoints between 0 (MIN_FAN_SETPOINT) and 100 (MAX_FAN_SETPOINT).
# See the table below for all the setpoints.
#
# | Mode | Setpoint | Min | Max |
# |:------------|----------|:----------------------|:----------------------|
# | HOME | Supply | AWAY Supply setpoint | 100 |
# | HOME | Extract | AWAY Extract setpoint | 100 |
# | AWAY | Supply | 30 | HOME Supply setpoint |
# | AWAY | Extract | 30 | HOME Extract setpoint |
# | HIGH | Supply | HOME Supply setpoint | 100 |
# | HIGH | Extract | HOME Extract setpoint | 100 |
# | COOKER_HOOD | Supply | 30 | 100 |
# | COOKER_HOOD | Extract | 30 | 100 |
# | FIREPLACE | Supply | 30 | 100 |
# | FIREPLACE | Extract | 30 | 100 |
NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
FlexitNumberEntityDescription(
key="away_extract_fan_setpoint",
@@ -66,7 +45,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_away,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda device: int(device.fan_setpoint_extract_air_home),
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="away_supply_fan_setpoint",
@@ -78,7 +57,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_away,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda device: int(device.fan_setpoint_supply_air_home),
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="cooker_hood_extract_fan_setpoint",
@@ -89,8 +68,8 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_extract_air_cooker,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_cooker,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="cooker_hood_supply_fan_setpoint",
@@ -101,8 +80,8 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_supply_air_cooker,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_cooker,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="fireplace_extract_fan_setpoint",
@@ -113,8 +92,8 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_extract_air_fire,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_fire,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="fireplace_supply_fan_setpoint",
@@ -125,8 +104,8 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_supply_air_fire,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_fire,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_min_value_fn=lambda _: _MIN_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda _: 30,
),
FlexitNumberEntityDescription(
key="high_extract_fan_setpoint",
@@ -137,7 +116,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_extract_air_high,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_high,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_home),
),
FlexitNumberEntityDescription(
@@ -149,7 +128,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_supply_air_high,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_high,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_home),
),
FlexitNumberEntityDescription(
@@ -161,7 +140,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_extract_air_home,
set_native_value_fn=lambda device: device.set_fan_setpoint_extract_air_home,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda device: int(device.fan_setpoint_extract_air_away),
),
FlexitNumberEntityDescription(
@@ -173,7 +152,7 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
native_value_fn=lambda device: device.fan_setpoint_supply_air_home,
set_native_value_fn=lambda device: device.set_fan_setpoint_supply_air_home,
native_unit_of_measurement=PERCENTAGE,
native_max_value_fn=lambda _: _MAX_FAN_SETPOINT,
native_max_value_fn=lambda _: 100,
native_min_value_fn=lambda device: int(device.fan_setpoint_supply_air_away),
),
)
@@ -53,5 +53,5 @@
"documentation": "https://www.home-assistant.io/integrations/flux_led",
"iot_class": "local_push",
"loggers": ["flux_led"],
"requirements": ["flux-led==1.1.0"]
"requirements": ["flux-led==1.0.4"]
}
@@ -27,7 +27,6 @@ class FritzboxCoordinatorData:
devices: dict[str, FritzhomeDevice]
templates: dict[str, FritzhomeTemplate]
supported_color_properties: dict[str, tuple[dict, list]]
class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]):
@@ -50,7 +49,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices: set[str] = set()
self.new_templates: set[str] = set()
self.data = FritzboxCoordinatorData({}, {}, {})
self.data = FritzboxCoordinatorData({}, {})
async def async_setup(self) -> None:
"""Set up the coordinator."""
@@ -121,7 +120,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
devices = self.fritz.get_devices()
device_data = {}
supported_color_properties = self.data.supported_color_properties
for device in devices:
# assume device as unavailable, see #55799
if (
@@ -138,13 +136,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
device_data[device.ain] = device
# pre-load supported colors and color temps for new devices
if device.has_color and device.ain not in supported_color_properties:
supported_color_properties[device.ain] = (
device.get_colors(),
device.get_color_temps(),
)
template_data = {}
if self.has_templates:
templates = self.fritz.get_templates()
@@ -154,11 +145,7 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.new_devices = device_data.keys() - self.data.devices.keys()
self.new_templates = template_data.keys() - self.data.templates.keys()
return FritzboxCoordinatorData(
devices=device_data,
templates=template_data,
supported_color_properties=supported_color_properties,
)
return FritzboxCoordinatorData(devices=device_data, templates=template_data)
async def _async_update_data(self) -> FritzboxCoordinatorData:
"""Fetch all device data."""
+28 -20
View File
@@ -57,6 +57,7 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
) -> None:
"""Initialize the FritzboxLight entity."""
super().__init__(coordinator, ain, None)
self._supported_hs: dict[int, list[int]] = {}
self._attr_supported_color_modes = {ColorMode.ONOFF}
if self.data.has_color:
@@ -64,26 +65,6 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
elif self.data.has_level:
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
(supported_colors, supported_color_temps) = (
coordinator.data.supported_color_properties.get(self.data.ain, ({}, []))
)
# Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
# Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
self._supported_hs: dict[int, list[int]] = {}
for values in supported_colors.values():
hue = int(values[0][0])
self._supported_hs[hue] = [
int(values[0][1]),
int(values[1][1]),
int(values[2][1]),
]
if supported_color_temps:
# only available for color bulbs
self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
@property
def is_on(self) -> bool:
"""If the light is currently on or off."""
@@ -167,3 +148,30 @@ class FritzboxLight(FritzBoxDeviceEntity, LightEntity):
"""Turn the light off."""
await self.hass.async_add_executor_job(self.data.set_state_off)
await self.coordinator.async_refresh()
async def async_added_to_hass(self) -> None:
"""Get light attributes from device after entity is added to hass."""
await super().async_added_to_hass()
def _get_color_data() -> tuple[dict, list]:
return (self.data.get_colors(), self.data.get_color_temps())
(
supported_colors,
supported_color_temps,
) = await self.hass.async_add_executor_job(_get_color_data)
if supported_color_temps:
# only available for color bulbs
self._attr_max_color_temp_kelvin = int(max(supported_color_temps))
self._attr_min_color_temp_kelvin = int(min(supported_color_temps))
# Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each.
# Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup
for values in supported_colors.values():
hue = int(values[0][0])
self._supported_hs[hue] = [
int(values[0][1]),
int(values[1][1]),
int(values[2][1]),
]
-161
View File
@@ -68,167 +68,6 @@ def get_inverter_status_message(code: StateType) -> InverterStatusCodeOption | N
return _INVERTER_STATUS_CODES.get(code) # type: ignore[arg-type]
INVERTER_ERROR_CODES: Final[dict[int, str]] = {
0: "no_error",
102: "ac_voltage_too_high",
103: "ac_voltage_too_low",
105: "ac_frequency_too_high",
106: "ac_frequency_too_low",
107: "ac_grid_outside_permissible_limits",
108: "stand_alone_operation_detected",
112: "rcmu_error",
240: "arc_detection_triggered",
241: "arc_detection_triggered",
242: "arc_detection_triggered",
243: "arc_detection_triggered",
301: "overcurrent_ac",
302: "overcurrent_dc",
303: "dc_module_over_temperature",
304: "ac_module_over_temperature",
305: "no_power_fed_in_despite_closed_relay",
306: "pv_output_too_low_for_feeding_energy_into_the_grid",
307: "low_pv_voltage_dc_input_voltage_too_low",
308: "intermediate_circuit_voltage_too_high",
309: "dc_input_voltage_mppt_1_too_high",
311: "polarity_of_dc_strings_reversed",
313: "dc_input_voltage_mppt_2_too_high",
314: "current_sensor_calibration_timeout",
315: "ac_current_sensor_error",
316: "interrupt_check_fail",
325: "overtemperature_in_connection_area",
326: "fan_1_error",
327: "fan_2_error",
401: "no_communication_with_power_stage_set",
406: "ac_module_temperature_sensor_faulty_l1",
407: "ac_module_temperature_sensor_faulty_l2",
408: "dc_component_measured_in_grid_too_high",
412: "fixed_voltage_mode_out_of_range",
415: "safety_cut_out_triggered",
416: "no_communication_between_power_stage_and_control_system",
417: "hardware_id_problem",
419: "unique_id_conflict",
420: "no_communication_with_hybrid_manager",
421: "hid_range_error",
425: "no_communication_with_power_stage_set",
426: "possible_hardware_fault",
427: "possible_hardware_fault",
428: "possible_hardware_fault",
431: "software_problem",
436: "functional_incompatibility_between_pc_boards",
437: "power_stage_set_problem",
438: "functional_incompatibility_between_pc_boards",
443: "intermediate_circuit_voltage_too_low_or_asymmetric",
445: "compatibility_error_invalid_power_stage_configuration",
447: "insulation_fault",
448: "neutral_conductor_not_connected",
450: "guard_cannot_be_found",
451: "memory_error_detected",
452: "communication",
502: "insulation_error_on_solar_panels",
509: "no_energy_fed_into_grid_past_24_hours",
515: "no_communication_with_filter",
516: "no_communication_with_storage_unit",
517: "power_derating_due_to_high_temperature",
518: "internal_dsp_malfunction",
519: "no_communication_with_storage_unit",
520: "no_energy_fed_by_mppt1_past_24_hours",
522: "dc_low_string_1",
523: "dc_low_string_2",
558: "functional_incompatibility_between_pc_boards",
559: "functional_incompatibility_between_pc_boards",
560: "derating_caused_by_over_frequency",
564: "functional_incompatibility_between_pc_boards",
566: "arc_detector_switched_off",
567: "grid_voltage_dependent_power_reduction_active",
601: "can_bus_full",
603: "ac_module_temperature_sensor_faulty_l3",
604: "dc_module_temperature_sensor_faulty",
607: "rcmu_error",
608: "functional_incompatibility_between_pc_boards",
701: "internal_processor_status",
702: "internal_processor_status",
703: "internal_processor_status",
704: "internal_processor_status",
705: "internal_processor_status",
706: "internal_processor_status",
707: "internal_processor_status",
708: "internal_processor_status",
709: "internal_processor_status",
710: "internal_processor_status",
711: "internal_processor_status",
712: "internal_processor_status",
713: "internal_processor_status",
714: "internal_processor_status",
715: "internal_processor_status",
716: "internal_processor_status",
721: "eeprom_reinitialised",
722: "internal_processor_status",
723: "internal_processor_status",
724: "internal_processor_status",
725: "internal_processor_status",
726: "internal_processor_status",
727: "internal_processor_status",
728: "internal_processor_status",
729: "internal_processor_status",
730: "internal_processor_status",
731: "initialisation_error_usb_flash_drive_not_supported",
732: "initialisation_error_usb_stick_over_current",
733: "no_usb_flash_drive_connected",
734: "update_file_not_recognised_or_missing",
735: "update_file_does_not_match_device",
736: "write_or_read_error_occurred",
737: "file_could_not_be_opened",
738: "log_file_cannot_be_saved",
740: "initialisation_error_file_system_error_on_usb",
741: "error_during_logging_data_recording",
743: "error_during_update_process",
745: "update_file_corrupt",
746: "error_during_update_process",
751: "time_lost",
752: "real_time_clock_communication_error",
753: "real_time_clock_in_emergency_mode",
754: "internal_processor_status",
755: "internal_processor_status",
757: "real_time_clock_hardware_error",
758: "real_time_clock_in_emergency_mode",
760: "internal_hardware_error",
761: "internal_processor_status",
762: "internal_processor_status",
763: "internal_processor_status",
764: "internal_processor_status",
765: "internal_processor_status",
766: "emergency_power_derating_activated",
767: "internal_processor_status",
768: "different_power_limitation_in_hardware_modules",
772: "storage_unit_not_available",
773: "software_update_invalid_country_setup",
775: "pmc_power_stage_set_not_available",
776: "invalid_device_type",
781: "internal_processor_status",
782: "internal_processor_status",
783: "internal_processor_status",
784: "internal_processor_status",
785: "internal_processor_status",
786: "internal_processor_status",
787: "internal_processor_status",
788: "internal_processor_status",
789: "internal_processor_status",
790: "internal_processor_status",
791: "internal_processor_status",
792: "internal_processor_status",
793: "internal_processor_status",
794: "internal_processor_status",
1001: "insulation_measurement_triggered",
1024: "inverter_settings_changed_restart_required",
1030: "wired_shut_down_triggered",
1036: "grid_frequency_exceeded_limit_reconnecting",
1112: "mains_voltage_dependent_power_reduction",
1175: "too_little_dc_power_for_feed_in_operation",
1196: "inverter_required_setup_values_not_received",
65000: "dc_connection_inverter_battery_interrupted",
}
class MeterLocationCodeOption(StrEnum):
"""Meter location codes for Fronius meters."""
@@ -11,6 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/fronius",
"iot_class": "local_polling",
"loggers": ["pyfronius"],
"quality_scale": "gold",
"requirements": ["PyFronius==0.7.3"]
}
@@ -33,7 +33,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
INVERTER_ERROR_CODES,
SOLAR_NET_DISCOVERY_NEW,
InverterStatusCodeOption,
MeterLocationCodeOption,
@@ -206,15 +205,6 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [
FroniusSensorEntityDescription(
key="error_code",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
FroniusSensorEntityDescription(
key="error_message",
response_key="error_code",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=list(dict.fromkeys(INVERTER_ERROR_CODES.values())),
value_fn=INVERTER_ERROR_CODES.get, # type: ignore[arg-type]
),
FroniusSensorEntityDescription(
key="status_code",
@@ -73,107 +73,6 @@
"error_code": {
"name": "Error code"
},
"error_message": {
"name": "Error message",
"state": {
"no_error": "No error",
"ac_voltage_too_high": "AC voltage too high",
"ac_voltage_too_low": "AC voltage too low",
"ac_frequency_too_high": "AC frequency too high",
"ac_frequency_too_low": "AC frequency too low",
"ac_grid_outside_permissible_limits": "AC grid outside the permissible limits",
"stand_alone_operation_detected": "Stand alone operation detected",
"rcmu_error": "RCMU error",
"arc_detection_triggered": "Arc detection triggered",
"overcurrent_ac": "Overcurrent (AC)",
"overcurrent_dc": "Overcurrent (DC)",
"dc_module_over_temperature": "DC module over temperature",
"ac_module_over_temperature": "AC module over temperature",
"no_power_fed_in_despite_closed_relay": "No power being fed in, despite closed relay",
"pv_output_too_low_for_feeding_energy_into_the_grid": "PV output too low for feeding energy into the grid",
"low_pv_voltage_dc_input_voltage_too_low": "Low PV voltage - DC input voltage too low for feeding energy into the grid",
"intermediate_circuit_voltage_too_high": "Intermediate circuit voltage too high",
"dc_input_voltage_mppt_1_too_high": "DC input voltage MPPT 1 too high",
"polarity_of_dc_strings_reversed": "Polarity of DC strings reversed",
"dc_input_voltage_mppt_2_too_high": "DC input voltage MPPT 2 too high",
"current_sensor_calibration_timeout": "Current sensor calibration timeout",
"ac_current_sensor_error": "AC current sensor error",
"interrupt_check_fail": "Interrupt Check fail",
"overtemperature_in_connection_area": "Overtemperature in the connection area",
"fan_1_error": "Fan 1 error",
"fan_2_error": "Fan 2 error",
"no_communication_with_power_stage_set": "No communication with the power stage set possible",
"ac_module_temperature_sensor_faulty_l1": "AC module temperature sensor faulty (L1)",
"ac_module_temperature_sensor_faulty_l2": "AC module temperature sensor faulty (L2)",
"dc_component_measured_in_grid_too_high": "DC component measured in the grid too high",
"fixed_voltage_mode_out_of_range": "Fixed voltage mode has been selected instead of MPP voltage mode and the fixed voltage has been set to too low or too high a value",
"safety_cut_out_triggered": "Safety cut out via option card or RECERBO has triggered",
"no_communication_between_power_stage_and_control_system": "No communication possible between power stage set and control system",
"hardware_id_problem": "Hardware ID problem",
"unique_id_conflict": "Unique ID conflict",
"no_communication_with_hybrid_manager": "No communication possible with the Hybrid manager",
"hid_range_error": "HID range error",
"possible_hardware_fault": "Possible hardware fault",
"software_problem": "Software problem",
"functional_incompatibility_between_pc_boards": "Functional incompatibility (one or more PC boards in the inverter are not compatible with each other, e.g. after a PC board has been replaced)",
"power_stage_set_problem": "Power stage set problem",
"intermediate_circuit_voltage_too_low_or_asymmetric": "Intermediate circuit voltage too low or asymmetric",
"compatibility_error_invalid_power_stage_configuration": "Compatibility error (e.g. due to replacement of a PC board) - invalid power stage set configuration",
"insulation_fault": "Insulation fault",
"neutral_conductor_not_connected": "Neutral conductor not connected",
"guard_cannot_be_found": "Guard cannot be found",
"memory_error_detected": "Memory error detected",
"communication": "Communication error",
"insulation_error_on_solar_panels": "Insulation error on the solar panels",
"no_energy_fed_into_grid_past_24_hours": "No energy fed into the grid in the past 24 hours",
"no_communication_with_filter": "No communication with filter possible",
"no_communication_with_storage_unit": "No communication possible with the storage unit",
"power_derating_due_to_high_temperature": "Power derating caused by too high a temperature",
"internal_dsp_malfunction": "Internal DSP malfunction",
"no_energy_fed_by_mppt1_past_24_hours": "No energy fed into the grid by MPPT1 in the past 24 hours",
"dc_low_string_1": "DC low string 1",
"dc_low_string_2": "DC low string 2",
"derating_caused_by_over_frequency": "Derating caused by over-frequency",
"arc_detector_switched_off": "Arc detector switched off (e.g. during external arc monitoring)",
"grid_voltage_dependent_power_reduction_active": "Grid Voltage Dependent Power Reduction is active",
"can_bus_full": "CAN bus is full",
"ac_module_temperature_sensor_faulty_l3": "AC module temperature sensor faulty (L3)",
"dc_module_temperature_sensor_faulty": "DC module temperature sensor faulty",
"internal_processor_status": "Warning about the internal processor status. See status code for more information",
"eeprom_reinitialised": "EEPROM has been re-initialised",
"initialisation_error_usb_flash_drive_not_supported": "Initialisation error USB flash drive is not supported",
"initialisation_error_usb_stick_over_current": "Initialisation error Over current on USB stick",
"no_usb_flash_drive_connected": "No USB flash drive connected",
"update_file_not_recognised_or_missing": "Update file not recognised or not present",
"update_file_does_not_match_device": "Update file does not match the device, update file too old",
"write_or_read_error_occurred": "Write or read error occurred",
"file_could_not_be_opened": "File could not be opened",
"log_file_cannot_be_saved": "Log file cannot be saved (e.g. USB flash drive is write protected or full)",
"initialisation_error_file_system_error_on_usb": "Initialisation error in file system on USB flash drive",
"error_during_logging_data_recording": "Error during recording of logging data",
"error_during_update_process": "Error occurred during update process",
"update_file_corrupt": "Update file corrupt",
"time_lost": "Time lost",
"real_time_clock_communication_error": "Real Time Clock module communication error",
"real_time_clock_in_emergency_mode": "Internal error: Real Time Clock module is in emergency mode",
"real_time_clock_hardware_error": "Hardware error in the Real Time Clock module",
"internal_hardware_error": "Internal hardware error",
"emergency_power_derating_activated": "Emergency power derating activated",
"different_power_limitation_in_hardware_modules": "Different power limitation in the hardware modules",
"storage_unit_not_available": "Storage unit not available",
"software_update_invalid_country_setup": "Software update group 0 (invalid country setup)",
"pmc_power_stage_set_not_available": "PMC power stage set not available",
"invalid_device_type": "Invalid device type",
"insulation_measurement_triggered": "Insulation measurement triggered",
"inverter_settings_changed_restart_required": "Inverter settings have been changed, inverter restart required",
"wired_shut_down_triggered": "Wired shut down triggered",
"grid_frequency_exceeded_limit_reconnecting": "The grid frequency has exceeded a limit value when reconnecting",
"mains_voltage_dependent_power_reduction": "Mains voltage-dependent power reduction",
"too_little_dc_power_for_feed_in_operation": "Too little DC power for feed-in operation",
"inverter_required_setup_values_not_received": "Inverter required setup values could not be received",
"dc_connection_inverter_battery_interrupted": "DC connection between inverter and battery interrupted"
}
},
"status_code": {
"name": "Status code"
},
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20241230.0"]
"requirements": ["home-assistant-frontend==20241127.8"]
}
@@ -12,7 +12,7 @@
},
"device_config": {
"title": "Device configuration",
"description": "The PIN can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'",
"description": "The pin can be found via 'MENU button > Main Menu > System setting > Network > NetRemote PIN setup'",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
}
+3 -2
View File
@@ -96,9 +96,10 @@ class GenericCamera(Camera):
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
if self._stream_source:
self._stream_source = Template(self._stream_source, hass)
self._attr_supported_features = CameraEntityFeature.STREAM
self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False)
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
if self._stream_source:
self._attr_supported_features = CameraEntityFeature.STREAM
self.content_type = device_info[CONF_CONTENT_TYPE]
self.verify_ssl = device_info[CONF_VERIFY_SSL]
if device_info.get(CONF_RTSP_TRANSPORT):
+42 -130
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
import contextlib
from datetime import datetime, timedelta
from datetime import datetime
from errno import EHOSTUNREACH, EIO
import io
import logging
@@ -17,21 +17,18 @@ import PIL.Image
import voluptuous as vol
import yarl
from homeassistant.components import websocket_api
from homeassistant.components.camera import (
CAMERA_IMAGE_TIMEOUT,
DOMAIN as CAMERA_DOMAIN,
DynamicStreamSettings,
_async_get_image,
)
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.stream import (
CONF_RTSP_TRANSPORT,
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
HLS_PROVIDER,
RTSP_TRANSPORTS,
SOURCE_TIMEOUT,
Stream,
create_stream,
)
from homeassistant.config_entries import (
@@ -52,9 +49,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import config_validation as cv, template as template_helper
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.setup import async_prepare_setup_platform
from homeassistant.util import slugify
from .camera import GenericCamera, generate_auth
@@ -84,15 +79,6 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
IMAGE_PREVIEWS_ACTIVE = "previews"
class InvalidStreamException(HomeAssistantError):
"""Error to indicate an invalid stream."""
def __init__(self, error: str, details: str | None = None) -> None:
"""Initialize the error."""
super().__init__(error)
self.details = details
def build_schema(
user_input: Mapping[str, Any],
is_options_flow: bool = False,
@@ -245,16 +231,12 @@ def slug(
return None
async def async_test_and_preview_stream(
async def async_test_stream(
hass: HomeAssistant, info: Mapping[str, Any]
) -> Stream | None:
"""Verify that the stream is valid before we create an entity.
Returns the stream object if valid. Raises InvalidStreamException if not.
The stream object is used to preview the video in the UI.
"""
) -> dict[str, str]:
"""Verify that the stream is valid before we create an entity."""
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
return None
return {}
# Import from stream.worker as stream cannot reexport from worker
# without forcing the av dependency on default_config
# pylint: disable-next=import-outside-toplevel
@@ -266,7 +248,7 @@ async def async_test_and_preview_stream(
stream_source = stream_source.async_render(parse_result=False)
except TemplateError as err:
_LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
raise InvalidStreamException("template_error") from err
return {CONF_STREAM_SOURCE: "template_error"}
stream_options: dict[str, str | bool | float] = {}
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
@@ -275,10 +257,10 @@ async def async_test_and_preview_stream(
try:
url = yarl.URL(stream_source)
except ValueError as err:
raise InvalidStreamException("malformed_url") from err
except ValueError:
return {CONF_STREAM_SOURCE: "malformed_url"}
if not url.is_absolute():
raise InvalidStreamException("relative_url")
return {CONF_STREAM_SOURCE: "relative_url"}
if not url.user and not url.password:
username = info.get(CONF_USERNAME)
password = info.get(CONF_PASSWORD)
@@ -291,28 +273,29 @@ async def async_test_and_preview_stream(
stream_source,
stream_options,
DynamicStreamSettings(),
f"{DOMAIN}.test_stream",
"test_stream",
)
hls_provider = stream.add_provider(HLS_PROVIDER)
await stream.start()
if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
hass.async_create_task(stream.stop())
return {CONF_STREAM_SOURCE: "timeout"}
await stream.stop()
except StreamWorkerError as err:
raise InvalidStreamException("unknown_with_details", str(err)) from err
except PermissionError as err:
raise InvalidStreamException("stream_not_permitted") from err
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
except PermissionError:
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
except OSError as err:
if err.errno == EHOSTUNREACH:
raise InvalidStreamException("stream_no_route_to_host") from err
return {CONF_STREAM_SOURCE: "stream_no_route_to_host"}
if err.errno == EIO: # input/output error
raise InvalidStreamException("stream_io_error") from err
return {CONF_STREAM_SOURCE: "stream_io_error"}
raise
except HomeAssistantError as err:
if "Stream integration is not set up" in str(err):
raise InvalidStreamException("stream_not_set_up") from err
return {CONF_STREAM_SOURCE: "stream_not_set_up"}
raise
await stream.start()
if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
hass.async_create_task(stream.stop())
raise InvalidStreamException("timeout")
return stream
return {}
def register_preview(hass: HomeAssistant) -> None:
@@ -333,7 +316,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize Generic ConfigFlow."""
self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}
self.title = ""
@@ -344,6 +326,14 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return GenericOptionsFlowHandler()
def check_for_existing(self, options: dict[str, Any]) -> bool:
"""Check whether an existing entry is using the same URLs."""
return any(
entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL)
and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE)
for entry in self._async_current_entries()
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -359,17 +349,10 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "no_still_image_or_stream_url"
else:
errors, still_format = await async_test_still(hass, user_input)
try:
self.preview_stream = await async_test_and_preview_stream(
hass, user_input
)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
if err.details:
errors["error_details"] = err.details
self.preview_stream = None
errors = errors | await async_test_stream(hass, user_input)
if not errors:
user_input[CONF_CONTENT_TYPE] = still_format
user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
still_url = user_input.get(CONF_STILL_IMAGE_URL)
stream_url = user_input.get(CONF_STREAM_SOURCE)
name = (
@@ -382,9 +365,14 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
self.user_input = user_input
self.title = name
if still_url is None:
return self.async_create_entry(
title=self.title, data={}, options=self.user_input
)
# temporary preview for user to check the image
self.preview_cam = user_input
return await self.async_step_user_confirm()
return await self.async_step_user_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
elif self.user_input:
@@ -398,14 +386,11 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_user_confirm(
async def async_step_user_confirm_still(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user clicking confirm after still preview."""
if user_input:
if ha_stream := self.preview_stream:
# Kill off the temp stream we created.
await ha_stream.stop()
if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_user()
return self.async_create_entry(
@@ -414,7 +399,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
return self.async_show_form(
step_id="user_confirm",
step_id="user_confirm_still",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
@@ -422,14 +407,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
),
description_placeholders={"preview_url": preview_url},
errors=None,
preview="generic_camera",
)
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
class GenericOptionsFlowHandler(OptionsFlow):
"""Handle Generic IP Camera options."""
@@ -444,21 +423,13 @@ class GenericOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage Generic IP Camera options."""
errors: dict[str, str] = {}
description_placeholders = {}
hass = self.hass
if user_input is not None:
errors, still_format = await async_test_still(
hass, self.config_entry.options | user_input
)
try:
await async_test_and_preview_stream(hass, user_input)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
if err.details:
errors["error_details"] = err.details
# Stream preview during options flow not yet implemented
errors = errors | await async_test_stream(hass, user_input)
still_url = user_input.get(CONF_STILL_IMAGE_URL)
if not errors:
if still_url is None:
@@ -478,8 +449,6 @@ class GenericOptionsFlowHandler(OptionsFlow):
# temporary preview for user to check the image
self.preview_cam = data
return await self.async_step_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
return self.async_show_form(
step_id="init",
data_schema=build_schema(
@@ -487,7 +456,6 @@ class GenericOptionsFlowHandler(OptionsFlow):
True,
self.show_advanced_options,
),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -550,59 +518,3 @@ class CameraImagePreview(HomeAssistantView):
CAMERA_IMAGE_TIMEOUT,
)
return web.Response(body=image.content, content_type=image.content_type)
@websocket_api.websocket_command(
{
vol.Required("type"): "generic_camera/start_preview",
vol.Required("flow_id"): str,
vol.Optional("flow_type"): vol.Any("config_flow"),
vol.Optional("user_input"): dict,
}
)
@websocket_api.async_response
async def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate websocket handler for the camera still/stream preview."""
_LOGGER.debug("Generating websocket handler for generic camera preview")
flow_id = msg["flow_id"]
flow = cast(
GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
)
user_input = flow.preview_cam
# Create an EntityPlatform, needed for name translations
platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN)
entity_platform = EntityPlatform(
hass=hass,
logger=_LOGGER,
domain=CAMERA_DOMAIN,
platform_name=DOMAIN,
platform=platform,
scan_interval=timedelta(seconds=3600),
entity_namespace=None,
)
await entity_platform.async_load_translations()
ha_still_url = None
ha_stream_url = None
if user_input.get(CONF_STILL_IMAGE_URL):
ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}"
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
if ha_stream := flow.preview_stream:
ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
connection.send_message(
websocket_api.event_message(
msg["id"],
{"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}},
)
)
@@ -3,7 +3,7 @@
"name": "Generic Camera",
"codeowners": ["@davet2001"],
"config_flow": true,
"dependencies": ["http", "stream"],
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
@@ -39,11 +39,11 @@
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},
"user_confirm": {
"title": "Confirmation",
"description": "Please wait for previews to load...",
"user_confirm_still": {
"title": "Preview",
"description": "![Camera Still Image Preview]({preview_url})",
"data": {
"confirmed_ok": "Everything looks good."
"confirmed_ok": "This image looks good."
}
}
}
@@ -68,16 +68,15 @@
}
},
"confirm_still": {
"title": "Preview",
"description": "![Camera Still Image Preview]({preview_url})",
"title": "[%key:component::generic::config::step::user_confirm_still::title%]",
"description": "[%key:component::generic::config::step::user_confirm_still::description%]",
"data": {
"confirmed_ok": "This image looks good."
"confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]"
}
}
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]",
"unknown_with_details": "[%key:component::generic::config::error::unknown_with_details%]",
"already_exists": "[%key:component::generic::config::error::already_exists%]",
"unable_still_load": "[%key:component::generic::config::error::unable_still_load%]",
"unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]",
@@ -20,10 +20,6 @@ CONF_GAIN = "gain"
CONF_PROFILES = "profiles"
CONF_TEXT_TYPE = "text_type"
DEFAULT_SPEED = 1.0
DEFAULT_PITCH = 0
DEFAULT_GAIN = 0
# STT constants
CONF_STT_MODEL = "stt_model"
@@ -31,10 +31,7 @@ from .const import (
CONF_SPEED,
CONF_TEXT_TYPE,
CONF_VOICE,
DEFAULT_GAIN,
DEFAULT_LANG,
DEFAULT_PITCH,
DEFAULT_SPEED,
)
DEFAULT_VOICE = ""
@@ -107,15 +104,15 @@ def tts_options_schema(
),
vol.Optional(
CONF_SPEED,
default=defaults.get(CONF_SPEED, DEFAULT_SPEED),
default=defaults.get(CONF_SPEED, 1.0),
): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
vol.Optional(
CONF_PITCH,
default=defaults.get(CONF_PITCH, DEFAULT_PITCH),
default=defaults.get(CONF_PITCH, 0),
): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
vol.Optional(
CONF_GAIN,
default=defaults.get(CONF_GAIN, DEFAULT_GAIN),
default=defaults.get(CONF_GAIN, 0),
): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
vol.Optional(
CONF_PROFILES,
+3 -18
View File
@@ -35,10 +35,7 @@ from .const import (
CONF_SPEED,
CONF_TEXT_TYPE,
CONF_VOICE,
DEFAULT_GAIN,
DEFAULT_LANG,
DEFAULT_PITCH,
DEFAULT_SPEED,
DOMAIN,
)
from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema
@@ -194,23 +191,11 @@ class BaseGoogleCloudProvider:
ssml_gender=gender,
name=voice,
),
# Avoid: "This voice does not support speaking rate or pitch parameters at this time."
# by not specifying the fields unless they differ from the defaults
audio_config=texttospeech.AudioConfig(
audio_encoding=encoding,
speaking_rate=(
options[CONF_SPEED]
if options[CONF_SPEED] != DEFAULT_SPEED
else None
),
pitch=(
options[CONF_PITCH]
if options[CONF_PITCH] != DEFAULT_PITCH
else None
),
volume_gain_db=(
options[CONF_GAIN] if options[CONF_GAIN] != DEFAULT_GAIN else None
),
speaking_rate=options[CONF_SPEED],
pitch=options[CONF_PITCH],
volume_gain_db=options[CONF_GAIN],
effects_profile_id=options[CONF_PROFILES],
),
)
+3 -4
View File
@@ -9,7 +9,6 @@ from google.oauth2.credentials import Credentials
from googleapiclient.discovery import Resource, build
from googleapiclient.errors import HttpError
from googleapiclient.http import BatchHttpRequest, HttpRequest
from httplib2 import ServerNotFoundError
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
@@ -116,7 +115,7 @@ class AsyncConfigEntryAuth:
def response_handler(_, response, exception: HttpError) -> None:
if exception is not None:
raise GoogleTasksApiError(
f"Google Tasks API responded with error ({exception.reason or exception.status_code})"
f"Google Tasks API responded with error ({exception.status_code})"
) from exception
if response:
data = json.loads(response)
@@ -151,9 +150,9 @@ class AsyncConfigEntryAuth:
async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any:
try:
result = await self._hass.async_add_executor_job(request.execute)
except (HttpError, ServerNotFoundError) as err:
except HttpError as err:
raise GoogleTasksApiError(
f"Google Tasks API responded with: {err.reason or err.status_code})"
f"Google Tasks API responded with error ({err.status_code})"
) from err
if result:
_raise_if_error(result)
@@ -19,7 +19,7 @@ rules:
unique-config-entry: done
entity-unique-id: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-removal-instructions: todo
test-before-setup: done
docs-high-level-description: done
config-flow-test-coverage: done
@@ -33,37 +33,35 @@ rules:
config-entry-unloading: done
reauthentication-flow: done
action-exceptions: done
docs-installation-parameters: done
docs-installation-parameters: todo
integration-owner: done
parallel-updates: done
test-coverage: done
docs-configuration-parameters:
status: exempt
comment: The integration does not have any configuration parameters.
docs-configuration-parameters: todo
entity-unavailable: done
# Gold
docs-examples: done
docs-examples: todo
discovery-update-info: todo
entity-device-class: todo
entity-translations: todo
docs-data-update: done
docs-data-update: todo
entity-disabled-by-default: todo
discovery: todo
exception-translations: todo
devices: todo
docs-supported-devices: done
docs-supported-devices: todo
icon-translations: todo
docs-known-limitations: done
docs-known-limitations: todo
stale-devices: todo
docs-supported-functions: done
docs-supported-functions: todo
repair-issues: todo
reconfiguration-flow: todo
entity-category: todo
dynamic-devices: todo
docs-troubleshooting: done
docs-troubleshooting: todo
diagnostics: todo
docs-use-cases: done
docs-use-cases: todo
# Platinum
async-dependency: todo
@@ -122,7 +122,7 @@
"connectable": false
}
],
"codeowners": ["@bdraco"],
"codeowners": ["@bdraco", "@PierreAronnax"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
@@ -1 +0,0 @@
"""Virtual integration: Harvey."""
@@ -1,6 +0,0 @@
{
"domain": "harvey",
"name": "Harvey",
"integration_type": "virtual",
"supported_by": "aquacell"
}
@@ -64,10 +64,7 @@ from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
# for hassio to import its platforms
# backup is pre-imported to ensure that the backup integration does not load
# it from the event loop
from . import ( # noqa: F401
backup,
binary_sensor,
config_flow,
diagnostics,
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.2.2b5"],
"requirements": ["aiohasupervisor==0.2.2b2"],
"single_config_entry": true
}
+20 -23
View File
@@ -11,7 +11,7 @@ from pyheos import Heos, HeosError, HeosPlayer, const as heos_const
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import (
@@ -259,19 +259,21 @@ class GroupManager:
return group_info_by_entity_id
async def async_join_players(
self, leader_id: int, leader_entity_id: str, member_entity_ids: list[str]
self, leader_entity_id: str, member_entity_ids: list[str]
) -> None:
"""Create a group a group leader and member players."""
# Resolve HEOS player_id for each member entity_id
entity_id_to_player_id_map = self._get_entity_id_to_player_id_map()
member_ids: list[int] = []
for member in member_entity_ids:
member_id = entity_id_to_player_id_map.get(member)
if not member_id:
raise HomeAssistantError(
f"The group member {member} could not be resolved to a HEOS player."
)
member_ids.append(member_id)
leader_id = entity_id_to_player_id_map.get(leader_entity_id)
if not leader_id:
raise HomeAssistantError(
f"The group leader {leader_entity_id} could not be resolved to a HEOS"
" player."
)
member_ids = [
entity_id_to_player_id_map[member]
for member in member_entity_ids
if member in entity_id_to_player_id_map
]
try:
await self.controller.create_group(leader_id, member_ids)
@@ -283,8 +285,14 @@ class GroupManager:
err,
)
async def async_unjoin_player(self, player_id: int, player_entity_id: str):
async def async_unjoin_player(self, player_entity_id: str):
"""Remove `player_entity_id` from any group."""
player_id = self._get_entity_id_to_player_id_map().get(player_entity_id)
if not player_id:
raise HomeAssistantError(
f"The player {player_entity_id} could not be resolved to a HEOS player."
)
try:
await self.controller.create_group(player_id, [])
except HeosError as err:
@@ -337,17 +345,6 @@ class GroupManager:
self._disconnect_player_added()
self._disconnect_player_added = None
@callback
def register_media_player(self, player_id: int, entity_id: str) -> CALLBACK_TYPE:
"""Register a media player player_id with it's entity_id so it can be resolved later."""
self.entity_id_map[player_id] = entity_id
return lambda: self.unregister_media_player(player_id)
@callback
def unregister_media_player(self, player_id) -> None:
"""Remove a media player player_id from the entity_id map."""
self.entity_id_map.pop(player_id, None)
@property
def group_membership(self):
"""Provide access to group members for player entities."""
+3 -11
View File
@@ -160,11 +160,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
async_dispatcher_connect(self.hass, SIGNAL_HEOS_UPDATED, self._heos_updated)
)
# Register this player's entity_id so it can be resolved by the group manager
self.async_on_remove(
self._group_manager.register_media_player(
self._player.player_id, self.entity_id
)
)
self._group_manager.entity_id_map[self._player.player_id] = self.entity_id
async_dispatcher_send(self.hass, SIGNAL_HEOS_PLAYER_ADDED)
@log_command_error("clear playlist")
@@ -175,9 +171,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
@log_command_error("join_players")
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
await self._group_manager.async_join_players(
self._player.player_id, self.entity_id, group_members
)
await self._group_manager.async_join_players(self.entity_id, group_members)
@log_command_error("pause")
async def async_media_pause(self) -> None:
@@ -300,9 +294,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
@log_command_error("unjoin_player")
async def async_unjoin_player(self) -> None:
"""Remove this player from any group."""
await self._group_manager.async_unjoin_player(
self._player.player_id, self.entity_id
)
await self._group_manager.async_unjoin_player(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect the device when removed."""
+3 -11
View File
@@ -118,7 +118,9 @@ class HistoryStats:
<= current_period_end_timestamp
):
self._history_current_period.append(
HistoryState(new_state.state, new_state.last_changed_timestamp)
HistoryState(
new_state.state, new_state.last_changed.timestamp()
)
)
new_data = True
if not new_data and current_period_end_timestamp < now_timestamp:
@@ -129,16 +131,6 @@ class HistoryStats:
await self._async_history_from_db(
current_period_start_timestamp, current_period_end_timestamp
)
if event and (new_state := event.data["new_state"]) is not None:
if (
current_period_start_timestamp
<= floored_timestamp(new_state.last_changed)
<= current_period_end_timestamp
):
self._history_current_period.append(
HistoryState(new_state.state, new_state.last_changed_timestamp)
)
self._previous_run_before_start = False
seconds_matched, match_count = self._async_compute_seconds_and_changes(
@@ -113,17 +113,12 @@ class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity):
await self.hive.session.updateData(self.device)
self.device = await self.hive.sensor.getSensor(self.device)
self.attributes = self.device.get("attributes", {})
self._attr_is_on = self.device["status"]["state"]
if self.device["hiveType"] != "Connectivity":
self._attr_available = (
self.device["deviceData"].get("online") and "status" in self.device
)
self._attr_available = self.device["deviceData"].get("online")
else:
self._attr_available = True
if self._attr_available:
self._attr_is_on = self.device["status"].get("state")
class HiveSensorEntity(HiveEntity, BinarySensorEntity):
"""Hive Sensor Entity."""
@@ -14,7 +14,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN
@@ -35,9 +34,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
password = user_input[CONF_PASSWORD]
try:
async with AqualinkClient(
username, password, httpx_client=get_async_client(self.hass)
):
async with AqualinkClient(username, password):
pass
except AqualinkServiceUnauthorizedException:
errors["base"] = "invalid_auth"
@@ -10,7 +10,11 @@ rules:
This integration does not use polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow-test-coverage:
status: todo
comment: |
- use mock_desk_api
- merge test_user_step_auth_failed, test_user_step_cannot_connect and test_user_step_unknown_exception.
config-flow: done
dependency-transparency: done
docs-actions:
+38 -92
View File
@@ -3,22 +3,13 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from typing import Any
from iottycloud.device import Device
from iottycloud.lightswitch import LightSwitch
from iottycloud.outlet import Outlet
from iottycloud.verbs import (
COMMAND_TURNOFF,
COMMAND_TURNON,
LS_DEVICE_TYPE_UID,
OU_DEVICE_TYPE_UID,
)
from iottycloud.verbs import LS_DEVICE_TYPE_UID
from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -29,62 +20,31 @@ from .entity import IottyEntity
_LOGGER = logging.getLogger(__name__)
ENTITIES: dict[str, SwitchEntityDescription] = {
LS_DEVICE_TYPE_UID: SwitchEntityDescription(
key="light",
name=None,
device_class=SwitchDeviceClass.SWITCH,
),
OU_DEVICE_TYPE_UID: SwitchEntityDescription(
key="outlet",
name=None,
device_class=SwitchDeviceClass.OUTLET,
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IottyConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Activate the iotty Switch component."""
"""Activate the iotty LightSwitch component."""
_LOGGER.debug("Setup SWITCH entry id is %s", config_entry.entry_id)
coordinator = config_entry.runtime_data.coordinator
lightswitch_entities = [
IottySwitch(
coordinator=coordinator,
iotty_cloud=coordinator.iotty,
iotty_device=d,
entity_description=ENTITIES[LS_DEVICE_TYPE_UID],
entities = [
IottyLightSwitch(
coordinator=coordinator, iotty_cloud=coordinator.iotty, iotty_device=d
)
for d in coordinator.data.devices
if d.device_type == LS_DEVICE_TYPE_UID
if (isinstance(d, LightSwitch))
]
_LOGGER.debug("Found %d LightSwitches", len(lightswitch_entities))
outlet_entities = [
IottySwitch(
coordinator=coordinator,
iotty_cloud=coordinator.iotty,
iotty_device=d,
entity_description=ENTITIES[OU_DEVICE_TYPE_UID],
)
for d in coordinator.data.devices
if d.device_type == OU_DEVICE_TYPE_UID
if (isinstance(d, Outlet))
]
_LOGGER.debug("Found %d Outlets", len(outlet_entities))
entities = lightswitch_entities + outlet_entities
_LOGGER.debug("Found %d LightSwitches", len(entities))
async_add_entities(entities)
known_devices: set = config_entry.runtime_data.known_devices
for known_device in coordinator.data.devices:
if known_device.device_type in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID}:
if known_device.device_type == LS_DEVICE_TYPE_UID:
known_devices.add(known_device)
@callback
@@ -99,37 +59,21 @@ async def async_setup_entry(
# Add entities for devices which we've not yet seen
for device in devices:
if any(d.device_id == device.device_id for d in known_devices) or (
device.device_type not in {LS_DEVICE_TYPE_UID, OU_DEVICE_TYPE_UID}
if (
any(d.device_id == device.device_id for d in known_devices)
or device.device_type != LS_DEVICE_TYPE_UID
):
continue
iotty_entity: SwitchEntity
iotty_device: LightSwitch | Outlet
if device.device_type == LS_DEVICE_TYPE_UID:
if TYPE_CHECKING:
assert isinstance(device, LightSwitch)
iotty_device = LightSwitch(
device.device_id,
device.serial_number,
device.device_type,
device.device_name,
)
else:
if TYPE_CHECKING:
assert isinstance(device, Outlet)
iotty_device = Outlet(
device.device_id,
device.serial_number,
device.device_type,
device.device_name,
)
iotty_entity = IottySwitch(
iotty_entity = IottyLightSwitch(
coordinator=coordinator,
iotty_cloud=coordinator.iotty,
iotty_device=iotty_device,
entity_description=ENTITIES[device.device_type],
iotty_device=LightSwitch(
device.device_id,
device.serial_number,
device.device_type,
device.device_name,
),
)
entities.extend([iotty_entity])
@@ -141,27 +85,24 @@ async def async_setup_entry(
coordinator.async_add_listener(async_update_data)
class IottySwitch(IottyEntity, SwitchEntity):
"""Haas entity class for iotty switch."""
class IottyLightSwitch(IottyEntity, SwitchEntity):
"""Haas entity class for iotty LightSwitch."""
_attr_device_class: SwitchDeviceClass | None
_iotty_device: LightSwitch | Outlet
_attr_device_class = SwitchDeviceClass.SWITCH
_iotty_device: LightSwitch
def __init__(
self,
coordinator: IottyDataUpdateCoordinator,
iotty_cloud: IottyProxy,
iotty_device: LightSwitch | Outlet,
entity_description: SwitchEntityDescription,
iotty_device: LightSwitch,
) -> None:
"""Initialize the Switch device."""
"""Initialize the LightSwitch device."""
super().__init__(coordinator, iotty_cloud, iotty_device)
self.entity_description = entity_description
self._attr_device_class = entity_description.device_class
@property
def is_on(self) -> bool:
"""Return true if the Switch is on."""
"""Return true if the LightSwitch is on."""
_LOGGER.debug(
"Retrieve device status for %s ? %s",
self._iotty_device.device_id,
@@ -170,25 +111,30 @@ class IottySwitch(IottyEntity, SwitchEntity):
return self._iotty_device.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the Switch on."""
"""Turn the LightSwitch on."""
_LOGGER.debug("[%s] Turning on", self._iotty_device.device_id)
await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNON)
await self._iotty_cloud.command(
self._iotty_device.device_id, self._iotty_device.cmd_turn_on()
)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the Switch off."""
"""Turn the LightSwitch off."""
_LOGGER.debug("[%s] Turning off", self._iotty_device.device_id)
await self._iotty_cloud.command(self._iotty_device.device_id, COMMAND_TURNOFF)
await self._iotty_cloud.command(
self._iotty_device.device_id, self._iotty_device.cmd_turn_off()
)
await self.coordinator.async_request_refresh()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
device: LightSwitch | Outlet = next( # type: ignore[assignment]
device: Device = next(
device
for device in self.coordinator.data.devices
if device.device_id == self._iotty_device.device_id
)
self._iotty_device.is_on = device.is_on
if isinstance(device, LightSwitch):
self._iotty_device.is_on = device.is_on
self.async_write_ha_state()
+7 -7
View File
@@ -2,28 +2,29 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import MANUFACTURER, MODEL
from .coordinator import IronOSLiveDataCoordinator
from .coordinator import IronOSBaseCoordinator
class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]):
class IronOSBaseEntity(CoordinatorEntity[IronOSBaseCoordinator]):
"""Base IronOS entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: IronOSLiveDataCoordinator,
coordinator: IronOSBaseCoordinator,
entity_description: EntityDescription,
context: Any | None = None,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
super().__init__(coordinator, context=context)
self.entity_description = entity_description
self._attr_unique_id = (
@@ -31,8 +32,7 @@ class IronOSBaseEntity(CoordinatorEntity[IronOSLiveDataCoordinator]):
)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id
self._attr_device_info = DeviceInfo(
self.device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, coordinator.config_entry.unique_id)},
manufacturer=MANUFACTURER,
model=MODEL,
+7 -5
View File
@@ -336,10 +336,10 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up number entities from a config entry."""
coordinators = entry.runtime_data
coordinator = entry.runtime_data
async_add_entities(
IronOSNumberEntity(coordinators, description)
IronOSNumberEntity(coordinator, description)
for description in PINECIL_NUMBER_DESCRIPTIONS
)
@@ -351,13 +351,15 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
def __init__(
self,
coordinators: IronOSCoordinators,
coordinator: IronOSCoordinators,
entity_description: IronOSNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinators.live_data, entity_description)
super().__init__(
coordinator.live_data, entity_description, entity_description.characteristic
)
self.settings = coordinators.settings
self.settings = coordinator.settings
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
+5 -3
View File
@@ -164,13 +164,15 @@ class IronOSSelectEntity(IronOSBaseEntity, SelectEntity):
def __init__(
self,
coordinators: IronOSCoordinators,
coordinator: IronOSCoordinators,
entity_description: IronOSSelectEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinators.live_data, entity_description)
super().__init__(
coordinator.live_data, entity_description, entity_description.characteristic
)
self.settings = coordinators.settings
self.settings = coordinator.settings
@property
def current_option(self) -> str | None:
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["keba_kecontact"],
"quality_scale": "legacy",
"requirements": ["keba-kecontact==1.3.0"]
"requirements": ["keba-kecontact==1.1.0"]
}
@@ -1,92 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration is push-based.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: todo
comment: data_descriptions are missing
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
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 actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have any configuration parameters.
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This is a cloud service and does not benefit from device updates.
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category:
status: exempt
comment: |
The default ones are good.
entity-device-class:
status: exempt
comment: |
Knocki does not have a device class.
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that are disabled by default.
entity-translations:
status: exempt
comment: |
This integration does not have any translatable entities.
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
-3
View File
@@ -401,9 +401,6 @@ class KNXModule:
)
return ConnectionConfig(
auto_reconnect=True,
individual_address=self.entry.data.get(
CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload
),
secure_config=SecureConfig(
knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD),
knxkeys_file_path=_knxkeys_file,
+18 -125
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from typing import Any, Final, Literal
from typing import Any, Final
import voluptuous as vol
from xknx import XKNX
@@ -121,15 +121,6 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
self._gatewayscanner: GatewayScanner | None = None
self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None
@property
def _xknx(self) -> XKNX:
"""Return XKNX instance."""
if isinstance(self, OptionsFlow) and (
knx_module := self.hass.data.get(KNX_MODULE_KEY)
):
return knx_module.xknx
return XKNX()
@abstractmethod
def finish_flow(self) -> ConfigFlowResult:
"""Finish the flow."""
@@ -192,8 +183,14 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
CONF_KNX_ROUTING: CONF_KNX_ROUTING.capitalize(),
}
if isinstance(self, OptionsFlow) and (
knx_module := self.hass.data.get(KNX_MODULE_KEY)
):
xknx = knx_module.xknx
else:
xknx = XKNX()
self._gatewayscanner = GatewayScanner(
self._xknx, stop_on_found=0, timeout_in_seconds=2
xknx, stop_on_found=0, timeout_in_seconds=2
)
# keep a reference to the generator to scan in background until user selects a connection type
self._async_scan_gen = self._gatewayscanner.async_scan()
@@ -207,25 +204,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
CONF_KNX_AUTOMATIC: CONF_KNX_AUTOMATIC.capitalize()
} | supported_connection_types
default_connection_type: Literal["automatic", "tunneling", "routing"]
_current_conn = self.initial_data.get(CONF_KNX_CONNECTION_TYPE)
if _current_conn in (
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
):
default_connection_type = CONF_KNX_TUNNELING
elif _current_conn in (CONF_KNX_ROUTING, CONF_KNX_ROUTING_SECURE):
default_connection_type = CONF_KNX_ROUTING
elif CONF_KNX_AUTOMATIC in supported_connection_types:
default_connection_type = CONF_KNX_AUTOMATIC
else:
default_connection_type = CONF_KNX_TUNNELING
fields = {
vol.Required(
CONF_KNX_CONNECTION_TYPE, default=default_connection_type
): vol.In(supported_connection_types)
vol.Required(CONF_KNX_CONNECTION_TYPE): vol.In(supported_connection_types)
}
return self.async_show_form(
step_id="connection_type", data_schema=vol.Schema(fields)
@@ -236,7 +216,8 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
) -> ConfigFlowResult:
"""Select a tunnel from a list.
Will be skipped if the gateway scan was unsuccessful.
Will be skipped if the gateway scan was unsuccessful
or if only one gateway was found.
"""
if user_input is not None:
if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL:
@@ -266,8 +247,6 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
user_password=None,
tunnel_endpoint_ia=None,
)
if connection_type == CONF_KNX_TUNNELING_TCP:
return await self.async_step_tcp_tunnel_endpoint()
if connection_type == CONF_KNX_TUNNELING_TCP_SECURE:
return await self.async_step_secure_key_source_menu_tunnel()
self.new_title = f"Tunneling @ {self._selected_tunnel}"
@@ -276,99 +255,16 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
if not self._found_tunnels:
return await self.async_step_manual_tunnel()
tunnel_options = [
selector.SelectOptionDict(
value=str(tunnel),
label=(
f"{tunnel}"
f"{' TCP' if tunnel.supports_tunnelling_tcp else ' UDP'}"
f"{' 🔐 Secure tunneling' if tunnel.tunnelling_requires_secure else ''}"
),
)
errors: dict = {}
tunnel_options = {
str(tunnel): f"{tunnel}{' 🔐' if tunnel.tunnelling_requires_secure else ''}"
for tunnel in self._found_tunnels
]
tunnel_options.append(
selector.SelectOptionDict(
value=OPTION_MANUAL_TUNNEL, label=OPTION_MANUAL_TUNNEL
)
)
default_tunnel = next(
(
str(tunnel)
for tunnel in self._found_tunnels
if tunnel.ip_addr == self.initial_data.get(CONF_HOST)
),
vol.UNDEFINED,
)
fields = {
vol.Required(
CONF_KNX_GATEWAY, default=default_tunnel
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=tunnel_options,
mode=selector.SelectSelectorMode.LIST,
)
)
}
tunnel_options |= {OPTION_MANUAL_TUNNEL: OPTION_MANUAL_TUNNEL}
fields = {vol.Required(CONF_KNX_GATEWAY): vol.In(tunnel_options)}
return self.async_show_form(step_id="tunnel", data_schema=vol.Schema(fields))
async def async_step_tcp_tunnel_endpoint(
self, user_input: dict | None = None
) -> ConfigFlowResult:
"""Select specific tunnel endpoint for plain TCP connection."""
if user_input is not None:
selected_tunnel_ia: str | None = (
None
if user_input[CONF_KNX_TUNNEL_ENDPOINT_IA] == CONF_KNX_AUTOMATIC
else user_input[CONF_KNX_TUNNEL_ENDPOINT_IA]
)
self.new_entry_data |= KNXConfigEntryData(
tunnel_endpoint_ia=selected_tunnel_ia,
)
self.new_title = (
f"{selected_tunnel_ia or 'Tunneling'} @ {self._selected_tunnel}"
)
return self.finish_flow()
# this step is only called from async_step_tunnel so self._selected_tunnel is always set
assert self._selected_tunnel
# skip if only one tunnel endpoint or no tunnelling slot infos
if len(self._selected_tunnel.tunnelling_slots) <= 1:
return self.finish_flow()
tunnel_endpoint_options = [
selector.SelectOptionDict(
value=CONF_KNX_AUTOMATIC, label=CONF_KNX_AUTOMATIC.capitalize()
)
]
_current_ia = self._xknx.current_address
tunnel_endpoint_options.extend(
selector.SelectOptionDict(
value=str(slot),
label=(
f"{slot} - {'current connection' if slot == _current_ia else 'occupied' if not slot_status.free else 'free'}"
),
)
for slot, slot_status in self._selected_tunnel.tunnelling_slots.items()
)
default_endpoint = (
self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC
)
return self.async_show_form(
step_id="tcp_tunnel_endpoint",
data_schema=vol.Schema(
{
vol.Required(
CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=tunnel_endpoint_options,
mode=selector.SelectSelectorMode.LIST,
)
),
}
),
step_id="tunnel", data_schema=vol.Schema(fields), errors=errors
)
async def async_step_manual_tunnel(
@@ -716,15 +612,12 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
)
for endpoint in self._tunnel_endpoints
)
default_endpoint = (
self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA) or CONF_KNX_AUTOMATIC
)
return self.async_show_form(
step_id="knxkeys_tunnel_select",
data_schema=vol.Schema(
{
vol.Required(
CONF_KNX_TUNNEL_ENDPOINT_IA, default=default_endpoint
CONF_KNX_TUNNEL_ENDPOINT_IA, default=CONF_KNX_AUTOMATIC
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=tunnel_endpoint_options,
+1 -1
View File
@@ -12,7 +12,7 @@
"requirements": [
"xknx==3.4.0",
"xknxproject==3.8.1",
"knx-frontend==2024.12.26.233449"
"knx-frontend==2024.11.16.205004"
],
"single_config_entry": true
}
+24 -66
View File
@@ -3,30 +3,16 @@
"step": {
"connection_type": {
"title": "KNX connection",
"description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.) \n\n 'Tunneling' will connect to a specific KNX IP interface over a tunnel. \n\n 'Routing' will use Multicast to communicate with KNX IP routers.",
"description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.",
"data": {
"connection_type": "KNX Connection Type"
},
"data_description": {
"connection_type": "Please select the connection type you want to use for your KNX connection."
}
},
"tunnel": {
"title": "Tunnel",
"description": "Please select a gateway from the list.",
"data": {
"gateway": "Please select a gateway from the list."
},
"data_description": {
"gateway": "Select a KNX tunneling interface you want use for the connection."
}
},
"tcp_tunnel_endpoint": {
"title": "Tunnel endpoint",
"data": {
"tunnel_endpoint_ia": "Select the tunnel endpoint used for the connection."
},
"data_description": {
"tunnel_endpoint_ia": "'Automatic' selects a free tunnel endpoint for you when connecting. If you're unsure, this is the best option."
"gateway": "KNX Tunnel Connection"
}
},
"manual_tunnel": {
@@ -34,24 +20,23 @@
"description": "Please enter the connection information of your tunneling device.",
"data": {
"tunneling_type": "KNX Tunneling Type",
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"host": "[%key:common::config_flow::data::host%]",
"route_back": "Route back / NAT mode",
"local_ip": "Local IP interface"
},
"data_description": {
"tunneling_type": "Select the tunneling type of your KNX/IP tunneling device. Older interfaces may only support `UDP`.",
"port": "Port of the KNX/IP tunneling device.",
"host": "IP address or hostname of the KNX/IP tunneling device.",
"port": "Port used by the KNX/IP tunneling device.",
"route_back": "Enable if your KNXnet/IP tunneling server is behind NAT. Only applies for UDP connections.",
"local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery."
}
},
"secure_key_source_menu_tunnel": {
"title": "KNX IP-Secure",
"description": "How do you want to configure KNX/IP Secure?",
"description": "Select how you want to configure KNX/IP Secure.",
"menu_options": {
"secure_knxkeys": "Use a `.knxkeys` file providing IP secure keys",
"secure_knxkeys": "Use a `.knxkeys` file containing IP secure keys",
"secure_tunnel_manual": "Configure IP secure credentials manually"
}
},
@@ -65,23 +50,20 @@
},
"secure_knxkeys": {
"title": "Import KNX Keyring",
"description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.",
"description": "Please select a `.knxkeys` file to import.",
"data": {
"knxkeys_file": "Keyring file",
"knxkeys_password": "Keyring password"
"knxkeys_password": "The password to decrypt the `.knxkeys` file"
},
"data_description": {
"knxkeys_file": "Select a `.knxkeys` file. This can be exported from ETS.",
"knxkeys_password": "The password to open the `.knxkeys` file was set when exporting."
"knxkeys_password": "This was set when exporting the file from ETS."
}
},
"knxkeys_tunnel_select": {
"title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
"title": "Tunnel endpoint",
"description": "Select the tunnel used for connection.",
"data": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
},
"data_description": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
"user_id": "`Automatic` will use the first free tunnel endpoint."
}
},
"secure_tunnel_manual": {
@@ -93,7 +75,7 @@
"device_authentication": "Device authentication password"
},
"data_description": {
"user_id": "This usually is tunnel number +1. So first tunnel in the list presented in ETS would have User-ID `2`.",
"user_id": "This is often tunnel number +1. So 'Tunnel 2' would have User-ID '3'.",
"user_password": "Password for the specific tunnel connection set in the 'Properties' panel of the tunnel in ETS.",
"device_authentication": "This is set in the 'IP' panel of the interface in ETS."
}
@@ -106,8 +88,8 @@
"sync_latency_tolerance": "Network latency tolerance"
},
"data_description": {
"backbone_key": "Can be seen in the 'Security' report of your ETS project. Eg. `00112233445566778899AABBCCDDEEFF`",
"sync_latency_tolerance": "Should be equal to the backbone configuration of your ETS project. Default is `1000`"
"backbone_key": "Can be seen in the 'Security' report of an ETS project. Eg. '00112233445566778899AABBCCDDEEFF'",
"sync_latency_tolerance": "Default is 1000."
}
},
"routing": {
@@ -115,16 +97,13 @@
"description": "Please configure the routing options.",
"data": {
"individual_address": "Individual address",
"routing_secure": "KNX IP Secure Routing",
"routing_secure": "Use KNX IP Secure",
"multicast_group": "Multicast group",
"multicast_port": "Multicast port",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
},
"data_description": {
"individual_address": "KNX address to be used by Home Assistant, e.g. `0.0.4`",
"routing_secure": "Select if your installation uses encrypted communication according to the KNX IP Secure standard. This setting requires compatible devices and configuration. You'll be prompted for credentials in the next step.",
"multicast_group": "Multicast group used by your installation. Default is `224.0.23.12`",
"multicast_port": "Multicast port used by your installation. Default is `3671`",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
}
}
@@ -162,7 +141,7 @@
},
"data_description": {
"state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.",
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`",
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40",
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
}
},
@@ -171,27 +150,13 @@
"description": "[%key:component::knx::config::step::connection_type::description%]",
"data": {
"connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]"
},
"data_description": {
"connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]"
}
},
"tunnel": {
"title": "[%key:component::knx::config::step::tunnel::title%]",
"description": "[%key:component::knx::config::step::tunnel::description%]",
"data": {
"gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]"
},
"data_description": {
"gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]"
}
},
"tcp_tunnel_endpoint": {
"title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
"data": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
},
"data_description": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
}
},
"manual_tunnel": {
@@ -205,7 +170,6 @@
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
},
"data_description": {
"tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]",
"port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]",
"host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]",
"route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]",
@@ -236,17 +200,14 @@
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
},
"data_description": {
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
}
},
"knxkeys_tunnel_select": {
"title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
"title": "[%key:component::knx::config::step::knxkeys_tunnel_select::title%]",
"description": "[%key:component::knx::config::step::knxkeys_tunnel_select::description%]",
"data": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
},
"data_description": {
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
"user_id": "[%key:component::knx::config::step::knxkeys_tunnel_select::data::user_id%]"
}
},
"secure_tunnel_manual": {
@@ -287,9 +248,6 @@
},
"data_description": {
"individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]",
"routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]",
"multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]",
"multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]",
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
}
}
@@ -413,7 +371,7 @@
},
"event_register": {
"name": "Register knx_event",
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed.",
"fields": {
"address": {
"name": "[%key:component::knx::services::send::fields::address::name%]",
@@ -431,7 +389,7 @@
},
"exposure_register": {
"name": "Expose to KNX bus",
"description": "Adds or removes exposures to KNX bus. Only exposures added with this action can be removed.",
"description": "Adds or removes exposures to KNX bus. Only exposures added with this service can be removed.",
"fields": {
"address": {
"name": "[%key:component::knx::services::send::fields::address::name%]",
@@ -3,7 +3,6 @@
from collections.abc import Callable
from dataclasses import dataclass
from pylamarzocco.const import MachineModel
from pylamarzocco.models import LaMarzoccoMachineConfig
from homeassistant.components.binary_sensor import (
@@ -16,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -29,7 +28,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@@ -58,15 +57,6 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
),
)
SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
LaMarzoccoBinarySensorEntityDescription(
key="connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
is_on_fn=lambda config: config.scale.connected if config.scale else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -76,30 +66,11 @@ async def async_setup_entry(
"""Set up binary sensor entities."""
coordinator = entry.runtime_data.config_coordinator
entities = [
async_add_entities(
LaMarzoccoBinarySensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
]
if (
coordinator.device.model == MachineModel.LINEA_MINI
and coordinator.device.config.scale
):
entities.extend(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleBinarySensorEntity(coordinator, description)
for description in SCALE_ENTITIES
)
coordinator.new_device_callback.append(_async_add_new_scale)
async_add_entities(entities)
)
class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@@ -108,14 +79,6 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
entity_description: LaMarzoccoBinarySensorEntityDescription
@property
def is_on(self) -> bool | None:
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.device.config)
class LaMarzoccoScaleBinarySensorEntity(
LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
):
"""Binary sensor for La Marzocco scales."""
entity_description: LaMarzoccoBinarySensorEntityDescription
@@ -3,7 +3,6 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -15,9 +14,8 @@ from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -64,7 +62,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device
self.local_connection_configured = local_client is not None
self._local_client = local_client
self.new_device_callback: list[Callable] = []
async def _async_update_data(self) -> None:
"""Do the data update."""
@@ -89,13 +86,9 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally."""
_scale_address: str | None = None
async def _async_connect_websocket(self) -> None:
async def _async_setup(self) -> None:
"""Set up the coordinator."""
if self._local_client is not None and (
self._local_client.websocket is None or self._local_client.websocket.closed
):
if self._local_client is not None:
_LOGGER.debug("Init WebSocket in background task")
self.config_entry.async_create_background_task(
@@ -125,26 +118,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Fetch data from API endpoint."""
await self.device.get_config()
_LOGGER.debug("Current status: %s", str(self.device.config))
await self._async_connect_websocket()
self._async_add_remove_scale()
@callback
def _async_add_remove_scale(self) -> None:
"""Add or remove a scale when added or removed."""
if self.device.config.scale and not self._scale_address:
self._scale_address = self.device.config.scale.address
for scale_callback in self.new_device_callback:
scale_callback()
elif not self.device.config.scale and self._scale_address:
device_registry = dr.async_get(self.hass)
if device := device_registry.async_get_device(
identifiers={(DOMAIN, self._scale_address)}
):
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self._scale_address = None
class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):

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