mirror of
https://github.com/home-assistant/core.git
synced 2026-04-29 10:23:46 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 510ed54b0a | |||
| 42c7c1fed4 | |||
| fce770fedd | |||
| 7d10276345 | |||
| 99118703c3 | |||
| 1569b270c2 | |||
| 05c063f8d9 | |||
| c35bb0596d | |||
| 0b3699ce49 | |||
| 8cc55809f9 |
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -252,7 +252,6 @@ PRELOAD_STORAGE = [
|
||||
"assist_pipeline.pipelines",
|
||||
"core.analytics",
|
||||
"auth_module.totp",
|
||||
"backup",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class EcovacsNumberEntity(
|
||||
EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]],
|
||||
EcovacsDescriptionEntity[CapabilitySet[EventT, int]],
|
||||
NumberEntity,
|
||||
):
|
||||
"""Ecovacs number entity."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]),
|
||||
]
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": "",
|
||||
"data": {
|
||||
"confirmed_ok": "Everything looks good."
|
||||
"confirmed_ok": "This image looks good."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,16 +68,15 @@
|
||||
}
|
||||
},
|
||||
"confirm_still": {
|
||||
"title": "Preview",
|
||||
"description": "",
|
||||
"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,
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user