Compare commits

..

1 Commits

Author SHA1 Message Date
Franck Nijhof 05d8f62191 Avoid walking script variable ChainMap twice when tracing
TraceElement.update_variables iterated the variables mapping twice on
every traced step: once to build the baseline snapshot via dict() and
once more in the changed-variables diff comprehension. The script
variable scope is a ChainMap, which is considerably more expensive to
iterate than a plain dict.

Flatten the mapping once and reuse the snapshot for both the baseline
and the diff. The output is identical (the merged view of a ChainMap is
the same whether iterated directly or after flattening), and tracing
runs unconditionally on every automation and script step, so this is a
broad, always-on saving.
2026-06-13 08:38:29 +00:00
185 changed files with 823 additions and 3494 deletions
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.18"]
"requirements": ["aioacaia==0.1.17"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/acmeda",
"iot_class": "local_push",
"loggers": ["aiopulse"],
"requirements": ["aiopulse==0.4.7"]
"requirements": ["aiopulse==0.4.6"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.4"]
"requirements": ["aioamazondevices==14.0.3"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.1"]
"requirements": ["anova-wifi==0.17.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.2"]
"requirements": ["anthemav==1.4.1"]
}
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
}
+1 -1
View File
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.6"]
"requirements": ["blinkpy==0.25.2"]
}
@@ -26,12 +26,6 @@
"description": "The credentials for {username} need to be updated",
"title": "Re-authenticate Blink"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.3"]
"requirements": ["bluecurrent-api==1.3.2"]
}
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
ActiveConnection,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
@@ -45,6 +45,7 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import (
CONF_EVENT,
DATA_COMPONENT,
DOMAIN,
EVENT_DESCRIPTION,
@@ -13,6 +13,9 @@ if TYPE_CHECKING:
DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
# pylint: disable-next=home-assistant-duplicate-const
CONF_EVENT = "event"
class CalendarEntityFeature(IntFlag):
"""Supported features of the calendar entity."""
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.5"]
"requirements": ["aiocomelit==2.0.3"]
}
@@ -8,7 +8,7 @@ from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL, SERVICE_RELOAD
from homeassistant.const import MATCH_ALL
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -53,6 +53,7 @@ from .const import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
)
from .default_agent import async_setup_default_agent
@@ -19,6 +19,8 @@ ATTR_AGENT_ID = "agent_id"
ATTR_CONVERSATION_ID = "conversation_id"
SERVICE_PROCESS = "process"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.3.3"],
"requirements": ["denonavr==1.3.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -9,6 +9,6 @@
"iot_class": "local_push",
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
"quality_scale": "silver",
"requirements": ["devolo-home-control-api==0.19.1"],
"requirements": ["devolo-home-control-api==0.19.0"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.3.2",
"aiodiscover==3.3.1",
"cached-ipaddress==1.1.2"
]
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.7.0"],
"requirements": ["eheimdigital==1.6.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.9"],
"requirements": ["pyenphase==2.4.8"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -19,7 +19,7 @@
"requirements": [
"aioesphomeapi==45.3.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.4"
"bleak-esphome==3.9.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -57,7 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) ->
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=f"{ble_device_info['model']} {cometblue_device.device.address}",
manufacturer=ble_device_info["manufacturer"],
model=ble_device_info["model"],
@@ -13,11 +13,6 @@
"discovery_confirm": {
"description": "Do you want to set up {model} {id} ({ipaddr})?"
},
"pick_device": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
+1 -1
View File
@@ -5,7 +5,7 @@
"data_description_password": "Password for the FRITZ!Box.",
"data_description_port": "Leave empty to use the default port.",
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
"data_description_username": "Username for the FRITZ!Box.",
"data_feature_device_tracking": "Enable network device tracking"
},
"config": {
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/geniushub",
"iot_class": "local_polling",
"loggers": ["geniushubclient"],
"requirements": ["geniushub-client==0.7.4"]
"requirements": ["geniushub-client==0.7.1"]
}
@@ -11,7 +11,6 @@
"step": {
"user": {
"data": {
"device": "[%key:common::config_flow::data::device%]",
"ip_address": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
@@ -151,13 +151,6 @@ class HolidayCalendarEntity(CalendarEntity):
"""Set up first update."""
self._update_state_and_setup_listener()
async def async_will_remove_from_hass(self) -> None:
"""Cancel listener when removing."""
await super().async_will_remove_from_hass()
if self.unsub:
self.unsub()
self.unsub = None
def update_event(self, now: datetime) -> CalendarEvent | None:
"""Return the next upcoming event."""
next_holiday = None
@@ -27,7 +27,6 @@ from homematicip.device import (
PassageDetector,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
RotaryHandleSensor,
SmokeDetector,
SoilMoistureSensorInterface,
SwitchMeasuring,
@@ -167,7 +166,6 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
}
TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"]
WINDOW_STATE_VALUES = ["open", "closed", "tilted"]
def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
@@ -206,9 +204,6 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
RoomControlDeviceAnalog: lambda device: [
HomematicipTemperatureSensor(hap, device),
],
RotaryHandleSensor: lambda device: [
HomematicipWindowStateSensor(hap, device),
],
LightSensor: lambda device: [
HomematicipIlluminanceSensor(hap, device),
],
@@ -503,24 +498,6 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
return state_attr
class HomematicipWindowStateSensor(HomematicipGenericEntity, SensorEntity):
"""Representation of the HomematicIP rotary handle window state sensor."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = WINDOW_STATE_VALUES
_attr_translation_key = "window_state"
def __init__(self, hap: HomematicipHAP, device: RotaryHandleSensor) -> None:
"""Initialize the window state sensor."""
super().__init__(hap, device, feature_id="window_state")
@property
def native_value(self) -> str | None:
"""Return the state."""
window_state = getattr(self._device, "windowState", None)
return window_state.lower() if window_state is not None else None
class HomematicipFloorTerminalBlockMechanicChannelValve(
HomematicipGenericEntity, SensorEntity
):
@@ -98,14 +98,6 @@
"non_neutral": "Non-neutral",
"tilted": "Tilted"
}
},
"window_state": {
"name": "Window state",
"state": {
"closed": "[%key:common::state::closed%]",
"open": "[%key:common::state::open%]",
"tilted": "Tilted"
}
}
}
},
@@ -50,12 +50,14 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
translation_key="auth_failed",
) from error
except HomevoltConnectionError as error:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
@@ -172,10 +172,10 @@
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"communication_error": {
"message": "Error communicating with the Homevolt battery: {error}"
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"unknown_error": {
"message": "Unknown error from the Homevolt battery: {error}"
"message": "[%key:common::config_flow::error::unknown%]"
}
}
}
+1 -2
View File
@@ -18,8 +18,7 @@
"step": {
"init": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"id": "Hue bridge"
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Hue bridge."
+3 -5
View File
@@ -4,7 +4,7 @@ import logging
from huum.const import SaunaStatus
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.components.number import NumberEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -34,9 +34,7 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
"""Representation of a steamer."""
_attr_translation_key = "humidity"
_attr_device_class = NumberDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = "%"
_attr_native_max_value = 40
_attr_native_max_value = 10
_attr_native_min_value = 0
_attr_native_step = 1
@@ -49,7 +47,7 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.coordinator.data.humidity
return self.coordinator.data.target_humidity
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyimouapi==1.2.8"]
"requirements": ["pyimouapi==1.2.7"]
}
@@ -11,7 +11,6 @@ from indevolt_api import (
IndevoltConfig,
IndevoltEnergyMode,
IndevoltRealtimeAction,
IndevoltRealtimeState,
)
from homeassistant.config_entries import ConfigEntry
@@ -110,10 +109,6 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Push/write data values to given key on the device."""
return await self.api.set_data(sensor_key, value)
def async_optimistic_update(self, read_key: str, value: Any) -> None:
"""Optimistically update coordinator data without fetching from device."""
self.async_set_updated_data({**self.data, read_key: value})
async def async_switch_energy_mode(
self, target_mode: IndevoltEnergyMode, refresh: bool = True
) -> None:
@@ -147,9 +142,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if refresh:
self.async_optimistic_update(
IndevoltConfig.READ_ENERGY_MODE, target_mode
)
await self.async_request_refresh()
async def async_realtime_action(
self,
@@ -168,15 +161,10 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
match action:
case IndevoltRealtimeAction.CHARGE:
success = await self.api.charge(power, target_soc)
state = IndevoltRealtimeState.CHARGING
case IndevoltRealtimeAction.DISCHARGE:
success = await self.api.discharge(power, target_soc)
state = IndevoltRealtimeState.DISCHARGING
case IndevoltRealtimeAction.STOP:
success = await self.api.stop()
state = IndevoltRealtimeState.STANDBY
if not success:
raise HomeAssistantError(
@@ -184,15 +172,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_key="failed_to_execute_realtime_action",
)
self.async_set_updated_data(
{
**self.data,
IndevoltConfig.READ_ENERGY_MODE: IndevoltEnergyMode.REAL_TIME_CONTROL,
IndevoltConfig.READ_REALTIME_STATE: state,
IndevoltConfig.READ_REALTIME_TARGET_SOC: target_soc,
IndevoltConfig.READ_REALTIME_POWER_LIMIT: power,
}
)
await self.async_request_refresh()
def get_emergency_soc(self) -> int:
"""Get the emergency SOC value."""
+1 -3
View File
@@ -136,9 +136,7 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
)
if success:
self.coordinator.async_optimistic_update(
self.entity_description.read_key, int_value
)
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
+1 -3
View File
@@ -106,9 +106,7 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
)
if success:
self.coordinator.async_optimistic_update(
self.entity_description.read_key, value
)
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
+1 -1
View File
@@ -86,7 +86,7 @@ SENSORS: Final = (
),
# Real-time control state
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_STATE,
key=IndevoltConfig.READ_REALTIME_COMMAND,
translation_key="realtime_command",
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
device_class=SensorDeviceClass.ENUM,
+1 -8
View File
@@ -126,14 +126,7 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
)
if success:
read_value = (
self.entity_description.read_on_value
if value
else self.entity_description.read_off_value
)
self.coordinator.async_optimistic_update(
self.entity_description.read_key, read_value
)
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
@@ -21,7 +21,6 @@ from homeassistant.const import (
CONF_ICON,
CONF_ID,
CONF_NAME,
CONF_OPTIONS,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
@@ -38,6 +37,8 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "input_select"
CONF_INITIAL = "initial"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
SERVICE_SET_OPTIONS = "set_options"
STORAGE_KEY = DOMAIN
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyiskra"],
"requirements": ["pyiskra==0.1.29"]
"requirements": ["pyiskra==0.1.27"]
}
@@ -5,10 +5,6 @@
},
"step": {
"user": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Do you want to set up Islamic Prayer Times?",
"title": "Set up Islamic Prayer Times"
}
@@ -29,6 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -
try:
await hass.async_add_executor_job(train_schedule.query, start, destination)
except Exception as e:
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="request_timeout",
@@ -65,10 +65,5 @@
"name": "Trains +2"
}
}
},
"exceptions": {
"request_timeout": {
"message": "Timeout connecting to the Israel Rail API for {config_title}: {error}"
}
}
}
@@ -4,7 +4,7 @@ import logging
from jvcprojector import Command, JvcProjector
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, NAME
@@ -27,12 +27,8 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
super().__init__(coordinator, command)
self._attr_unique_id = coordinator.unique_id
# The config entry unique id is the device's formatted MAC address (set
# from the projector's MAC in the config flow), so it doubles as the
# network MAC connection.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
name=NAME,
model=self.device.model,
manufacturer=MANUFACTURER,
+2 -56
View File
@@ -1,7 +1,6 @@
"""The KNX integration."""
import contextlib
import logging
from pathlib import Path
from typing import Final
@@ -18,20 +17,11 @@ from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_KNX_DEFAULT_RATE_LIMIT,
CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_EXPOSE,
CONF_KNX_KNXKEY_FILENAME,
CONF_KNX_RATE_LIMIT,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
DATA_HASS_CONFIG,
DOMAIN,
KNX_MODULE_KEY,
KNX_TELEGRAM_DB_PATH_SQLITE,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
@@ -61,12 +51,11 @@ from .schema import (
)
from .services import async_setup_services
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY
from .websocket import register_panel
_KNX_YAML_CONFIG: Final = "knx_yaml_config"
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
@@ -158,44 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await register_panel(hass)
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
new_data = {**entry.data}
new_options = {**entry.options}
new_data.pop("telegram_log_size", None)
for key in (
CONF_KNX_STATE_UPDATER,
CONF_KNX_RATE_LIMIT,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
):
if key in new_data:
new_options[key] = new_data.pop(key)
new_options.setdefault(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, KNX_TELEGRAM_DB_RETENTION_DEFAULT
)
new_options.setdefault(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS, KNX_TELEGRAM_LOAD_HOURS_DEFAULT
)
new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER)
new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT)
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options, version=2
)
_LOGGER.info("Migration to version 2 successful")
return True
@@ -252,12 +203,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
with contextlib.suppress(FileNotFoundError):
(storage_dir / PROJECT_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / KNX_TELEGRAM_DB_PATH_SQLITE).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / f"{KNX_TELEGRAM_DB_PATH_SQLITE}-wal").unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / f"{KNX_TELEGRAM_DB_PATH_SQLITE}-shm").unlink()
(storage_dir / TELEGRAMS_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError, OSError):
(storage_dir / DOMAIN).rmdir()
+37 -71
View File
@@ -16,7 +16,6 @@ from xknx.io.self_description import request_description
from xknx.io.util import validate_ip as xknx_validate_ip
from xknx.secure.keyring import Keyring, XMLInterface
from homeassistant import data_entry_flow
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -49,8 +48,7 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
@@ -58,10 +56,9 @@ from .const import (
DEFAULT_ROUTING_IA,
DOMAIN,
KNX_MODULE_KEY,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
TELEGRAM_LOG_DEFAULT,
TELEGRAM_LOG_MAX,
KNXConfigEntryData,
KNXConfigEntryOptions,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .validation import ia_validator, ip_v4_validator
@@ -74,20 +71,14 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData(
local_ip=None,
multicast_group=DEFAULT_MCAST_GRP,
multicast_port=DEFAULT_MCAST_PORT,
route_back=False,
)
DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions(
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
route_back=False,
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT,
telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
telegram_log_size=TELEGRAM_LOG_DEFAULT,
)
CONF_KEYRING_FILE: Final = "knxkeys_file"
CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section"
CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type"
CONF_KNX_TUNNELING_TYPE_LABELS: Final = {
CONF_KNX_TUNNELING: "UDP (Tunneling v1)",
@@ -112,7 +103,7 @@ _PORT_SELECTOR = vol.All(
class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a KNX config flow."""
VERSION = 2
VERSION = 1
def __init__(self) -> None:
"""Initialize KNX config flow."""
@@ -193,7 +184,6 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=title,
data=DEFAULT_ENTRY_DATA | self.new_entry_data,
options=DEFAULT_ENTRY_OPTIONS,
)
async def async_step_user(
@@ -926,16 +916,17 @@ class KNXOptionsFlow(OptionsFlowWithReload):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize KNX options flow."""
self.initial_options = dict(config_entry.options)
self.new_entry_options: KNXConfigEntryOptions = {}
self.initial_data = dict(config_entry.data)
@callback
def finish_flow(self) -> ConfigFlowResult:
def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult:
"""Update the ConfigEntry and finish the flow."""
return self.async_create_entry(
title="",
data=self.initial_options | self.new_entry_options,
new_data = self.initial_data | new_entry_data
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
)
return self.async_create_entry(title="", data={})
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -948,29 +939,24 @@ class KNXOptionsFlow(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage KNX communication settings."""
if user_input is not None:
telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION]
self.new_entry_options |= KNXConfigEntryOptions(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
telegram_db_load_hours=telegram_store_section[
CONF_KNX_TELEGRAM_DB_LOAD_HOURS
],
telegram_db_retention_days=telegram_store_section[
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS
],
return self.finish_flow(
KNXConfigEntryData(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE],
)
)
return self.finish_flow()
data_schema = {
vol.Required(
CONF_KNX_STATE_UPDATER,
default=self.initial_options.get(
default=self.initial_data.get(
CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER
),
): selector.BooleanSelector(),
vol.Required(
CONF_KNX_RATE_LIMIT,
default=self.initial_options.get(
default=self.initial_data.get(
CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT
),
): vol.All(
@@ -983,47 +969,27 @@ class KNXOptionsFlow(OptionsFlowWithReload):
),
vol.Coerce(int),
),
vol.Required(CONF_KNX_TELEGRAM_STORE_SECTION): data_entry_flow.section(
vol.Schema(
{
vol.Required(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
default=self.initial_options.get(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="h",
),
),
vol.Coerce(int),
),
vol.Required(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
default=self.initial_options.get(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
),
),
vol.Coerce(int),
),
}
vol.Required(
CONF_KNX_TELEGRAM_LOG_SIZE,
default=self.initial_data.get(
CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=TELEGRAM_LOG_MAX,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(int),
),
}
return self.async_show_form(
step_id="communication_settings",
data_schema=vol.Schema(data_schema),
last_step=True,
description_placeholders={
"telegram_log_size_max": f"{TELEGRAM_LOG_MAX}",
},
)
+6 -23
View File
@@ -10,11 +10,9 @@ from xknx.telegram import Telegram
from homeassistant.components.climate import FAN_AUTO, FAN_OFF, HVACAction, HVACMode
from homeassistant.const import Platform
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.signal_type import SignalType
if TYPE_CHECKING:
from .knx_module import KNXModule
from .telegrams import TelegramDict
DOMAIN: Final = "knx"
KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN)
@@ -52,18 +50,9 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days"
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours"
KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days
KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day
KNX_TELEGRAM_DB_PATH_SQLITE: Final = "knx/telegrams.db" # relative to STORAGE_DIR
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
)
CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size"
TELEGRAM_LOG_DEFAULT: Final = 1000
TELEGRAM_LOG_MAX: Final = 25000 # ~10 MB or ~25 hours of reasonable bus load
##
# Secure constants
@@ -105,11 +94,10 @@ SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
SERVICE_KNX_READ: Final = "read"
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR: Final = "telegram_backend_error"
class KNXConfigEntryData(TypedDict, total=False):
"""Config entry data for the KNX integration."""
"""Config entry for the KNX integration."""
connection_type: str
individual_address: str
@@ -128,16 +116,11 @@ class KNXConfigEntryData(TypedDict, total=False):
knxkeys_password: str # not required
backbone_key: str | None # not required
sync_latency_tolerance: int | None # not required
class KNXConfigEntryOptions(TypedDict, total=False):
"""Config entry options for the KNX integration."""
# OptionsFlow only
state_updater: bool # default state updater: True -> expire 60; False -> init
rate_limit: int
# Integration only (not forwarded to xknx)
telegram_db_retention_days: int
telegram_db_load_hours: int
telegram_log_size: int # not required
class ColorTempModes(Enum):
@@ -39,9 +39,6 @@ async def async_get_config_entry_diagnostics(
}
diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT)
diag["config_entry_options"] = async_redact_data(
dict(config_entry.options), TO_REDACT
)
if proj_info := knx_module.project.info:
diag["project_info"] = async_redact_data(proj_info, "name")
+6 -6
View File
@@ -1,7 +1,6 @@
"""Base module for the KNX integration."""
import logging
from typing import cast
from xknx import XKNX
from xknx.core import XknxConnectionState
@@ -44,12 +43,13 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
KNX_ADDRESS,
KNXConfigEntryOptions,
TELEGRAM_LOG_DEFAULT,
)
from .device import KNXInterfaceDevice
from .entity import KnxEntityIdentifier
@@ -85,7 +85,7 @@ class KNXModule:
default_state_updater = (
TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60)
if self.entry.options[CONF_KNX_STATE_UPDATER]
if self.entry.data[CONF_KNX_STATE_UPDATER]
else TrackerOptions(
tracker_type=StateTrackerType.INIT, update_interval_min=60
)
@@ -93,7 +93,7 @@ class KNXModule:
self.xknx = XKNX(
address_format=self.project.get_address_format(),
connection_config=self.connection_config(),
rate_limit=self.entry.options[CONF_KNX_RATE_LIMIT],
rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT],
state_updater=default_state_updater,
)
self.xknx.connection_manager.register_connection_state_changed_cb(
@@ -103,7 +103,7 @@ class KNXModule:
hass=hass,
xknx=self.xknx,
project=self.project,
config=cast(KNXConfigEntryOptions, entry.options),
log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT),
)
self.interface_device = KNXInterfaceDevice(
hass=hass, entry=entry, xknx=self.xknx
@@ -131,7 +131,7 @@ class KNXModule:
async def stop(self, event: Event | None = None) -> None:
"""Stop XKNX object. Disconnect from tunneling or Routing device."""
await self.xknx.stop()
await self.telegrams.stop()
await self.telegrams.save_history()
def connection_config(self) -> ConnectionConfig:
"""Return the connection_config."""
+1 -2
View File
@@ -13,8 +13,7 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"knx-frontend==2026.6.1.213802",
"knx-telegram-store[sqlite]==0.3.2"
"knx-frontend==2026.6.1.213802"
],
"single_config_entry": true
}
+1 -26
View File
@@ -15,17 +15,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
if TYPE_CHECKING:
from .knx_module import KNXModule
from .telegrams import TelegramDict
from .const import (
CONF_KNX_KNXKEY_PASSWORD,
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
KNXConfigEntryData,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
CONF_KEYRING_FILE: Final = "knxkeys_file"
@@ -162,26 +160,3 @@ class DataSecureGroupIssueRepairFlow(RepairsFlow):
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
return self.async_create_entry(data={})
@callback
def async_create_telegram_storage_issue(hass: HomeAssistant) -> None:
"""Create a repair issue for storage initialization failure."""
ir.async_create_issue(
hass,
DOMAIN,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="telegram_storage_error",
)
@callback
def async_delete_telegram_storage_issue(hass: HomeAssistant) -> None:
"""Delete the repair issue for storage initialization failure."""
ir.async_delete_issue(
hass,
DOMAIN,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
)
+4 -19
View File
@@ -1129,10 +1129,6 @@
}
},
"title": "KNX Data Secure telegrams can't be decrypted"
},
"telegram_storage_error": {
"description": "The configured KNX telegram storage backend failed to initialize. As a result, KNX telegrams are currently not being stored. Check the logs for details on the error and ensure your database is accessible.",
"title": "KNX telegram storage error"
}
},
"options": {
@@ -1140,24 +1136,13 @@
"communication_settings": {
"data": {
"rate_limit": "Rate limit",
"state_updater": "State updater"
"state_updater": "State updater",
"telegram_log_size": "Telegram history limit"
},
"data_description": {
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`",
"state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option."
},
"sections": {
"telegram_store_section": {
"data": {
"telegram_db_load_hours": "Group monitor history",
"telegram_db_retention_days": "Retention period"
},
"data_description": {
"telegram_db_load_hours": "Number of hours of telegram history to load when the group monitor is opened.",
"telegram_db_retention_days": "Number of days to keep telegram history. Older telegrams are automatically deleted nightly at 3 AM. Set to `0` to delete all telegram history on every nightly run."
},
"name": "Telegram store settings"
}
"state_updater": "Sets the default behavior for reading state addresses from the KNX Bus.\nWhen enabled, Home Assistant will monitor each group address and read it from the bus if no value has been received for one hour.\nWhen disabled, state addresses will only be read once after a bus connection is established.\nThis behavior can be overridden for individual entities using the `sync_state` option.",
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
},
"title": "Communication settings"
}
+36 -263
View File
@@ -1,17 +1,8 @@
"""KNX Telegrams history and storage."""
"""KNX Telegram handler."""
import asyncio
import contextlib
from datetime import datetime
import logging
import os
from typing import Any, TypedDict
from collections import deque
from typing import Final, TypedDict
from knx_telegram_store import (
BufferedSqliteStore,
KnxTelegramStoreException,
StoredTelegram,
)
from xknx import XKNX
from xknx.dpt import DPTArray, DPTBase, DPTBinary
from xknx.dpt.dpt import DPTComplexData, DPTEnumData
@@ -19,35 +10,24 @@ from xknx.exceptions import XKNXException
from xknx.telegram import Telegram, TelegramDirection
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.storage import STORAGE_DIR, Store
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
from homeassistant.util.signal_type import SignalType
from .const import (
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
KNX_TELEGRAM_DB_PATH_SQLITE,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
KNXConfigEntryOptions,
)
from .const import DOMAIN
from .project import KNXProject
from .repairs import (
async_create_telegram_storage_issue,
async_delete_telegram_storage_issue,
STORAGE_VERSION: Final = 1
STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
)
_LOGGER = logging.getLogger(__name__)
# Hour of the day (local time) at which expired telegrams are evicted nightly.
EVICT_EXPIRED_HOUR = 3
# Interval at which buffered telegram writes are flushed to the database.
# Websocket queries flush on demand (``flush_first=True``), so the only telegrams
# at risk from a longer interval are those buffered during an ungraceful shutdown.
FLUSH_INTERVAL_SECONDS = 600
class DecodedTelegramPayload(TypedDict):
"""Decoded payload value and metadata."""
@@ -82,27 +62,14 @@ class Telegrams:
hass: HomeAssistant,
xknx: XKNX,
project: KNXProject,
config: KNXConfigEntryOptions,
log_size: int,
) -> None:
"""Initialize Telegrams class."""
self.hass = hass
self.project = project
self.config = config
self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS]
self.store: BufferedSqliteStore | None = None
self._uninitialized_store: BufferedSqliteStore | None = None
self._evict_expired_unsub: CALLBACK_TYPE | None = None
full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
self._uninitialized_store = BufferedSqliteStore(
full_path,
retention_days=self.retention_days,
flush_interval=FLUSH_INTERVAL_SECONDS,
self._history_store = Store[list[TelegramDict]](
hass, STORAGE_VERSION, STORAGE_KEY
)
self._xknx_telegram_cb_handle = (
xknx.telegram_queue.register_telegram_received_cb(
telegram_received_cb=self._xknx_telegram_cb,
@@ -114,132 +81,43 @@ class Telegrams:
self._xknx_data_secure_group_key_issue_cb,
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
self.last_ga_telegrams: dict[str, TelegramDict] = {}
async def load_history(self) -> None:
"""Load history from store."""
if self._uninitialized_store is None:
if (telegrams := await self._history_store.async_load()) is None:
return
try:
needs_migration = await self._uninitialized_store.needs_migration()
if needs_migration:
_LOGGER.warning(
"KNX telegram history database schema upgrade/migration is required. "
"This may take some time depending on your database size. Please do not restart Home Assistant"
)
await self._uninitialized_store.initialize()
else:
_LOGGER.debug("Initializing KNX telegram storage")
async with asyncio.timeout(10):
await self._uninitialized_store.initialize()
_LOGGER.info("Successfully initialized KNX telegram storage")
except TimeoutError:
_LOGGER.error("Timeout initializing KNX telegram storage")
await self._abort_store_init()
if self.recent_telegrams.maxlen == 0:
await self._history_store.async_remove()
return
except KnxTelegramStoreException as err:
_LOGGER.error(
"Database error initializing KNX telegram storage: %s",
err,
)
await self._abort_store_init()
return
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error initializing KNX telegram storage: %s",
err,
)
await self._abort_store_init()
return
async_delete_telegram_storage_issue(self.hass)
self.store = self._uninitialized_store
self.store.start()
self._uninitialized_store = None
for telegram in telegrams:
# tuples are stored as lists in JSON
if isinstance(telegram["payload"], list):
telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable]
self.recent_telegrams.extend(telegrams)
self.last_ga_telegrams = {
t["destination"]: t for t in telegrams if t["payload"] is not None
}
# Evict telegrams older than the retention period once a night. A
# retention of 0 days means all telegrams are deleted on each run.
self._evict_expired_unsub = async_track_time_change(
self.hass,
self._async_evict_expired,
hour=EVICT_EXPIRED_HOUR,
minute=0,
second=0,
)
# Migrate legacy JSON storage if it exists
await self.migrate_telegrams()
# Hydrate last_ga_telegrams from store
try:
result = await self.store.get_last_unique_telegrams()
except KnxTelegramStoreException as err:
_LOGGER.warning("Database error hydrating last_ga_telegrams: %s", err)
return
except Exception as err: # noqa: BLE001
_LOGGER.warning("Error hydrating last_ga_telegrams: %s", err)
return
for m in result:
if m.payload is not None:
t_dict = self.model_to_dict(m)
self.last_ga_telegrams[t_dict["destination"]] = t_dict
_LOGGER.debug("Hydrated %d unique telegrams from store", len(result))
async def _abort_store_init(self) -> None:
"""Create a repair issue and tear down a store that failed to init."""
async_create_telegram_storage_issue(self.hass)
if self._uninitialized_store is not None:
with contextlib.suppress(Exception):
await self._uninitialized_store.close()
self._uninitialized_store = None
async def _async_evict_expired(self, now: datetime) -> None:
"""Delete telegrams older than the configured retention period."""
if self.store is None:
return
try:
deleted = await self.store.evict_expired()
except KnxTelegramStoreException as err:
_LOGGER.warning("Database error evicting expired KNX telegrams: %s", err)
return
_LOGGER.debug("Evicted %d expired KNX telegrams from storage", deleted)
async def stop(self) -> None:
"""Stop history store."""
if self._evict_expired_unsub is not None:
self._evict_expired_unsub()
self._evict_expired_unsub = None
if self.store is None:
return
try:
await self.store.stop()
except KnxTelegramStoreException as err:
_LOGGER.warning(
"Database error stopping KNX telegram storage backend: %s", err
)
except Exception as err: # noqa: BLE001
_LOGGER.warning("Error stopping KNX telegram storage backend: %s", err)
async def save_history(self) -> None:
"""Save history to store."""
if self.recent_telegrams:
await self._history_store.async_save(list(self.recent_telegrams))
def _xknx_telegram_cb(self, telegram: Telegram) -> None:
"""Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
if telegram_dict["payload"] is not None:
# exclude GroupValueRead telegrams
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
# Store in history store
if self.store is not None:
self.store.store_sync(self.dict_to_model(telegram_dict))
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
"""Handle telegrams with undecodable data secure payload from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
# Store in history store
if self.store is not None:
self.store.store_sync(self.dict_to_model(telegram_dict))
self.recent_telegrams.append(telegram_dict)
async_dispatcher_send(
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
)
@@ -290,111 +168,6 @@ class Telegrams:
value=value,
)
def dict_to_model(self, t: TelegramDict) -> StoredTelegram:
"""Convert a TelegramDict to a StoredTelegram model."""
value = t["value"]
value_numeric: float | None = None
if isinstance(value, (int, float)):
value_numeric = float(value)
payload: Any = t["payload"]
if isinstance(payload, list):
payload = tuple(payload)
return StoredTelegram(
timestamp=dt_util.parse_datetime(t["timestamp"], raise_on_error=True),
source=t["source"],
destination=t["destination"],
direction=t["direction"],
telegramtype=t["telegramtype"],
payload=payload,
value=value,
value_numeric=value_numeric,
dpt_main=t["dpt_main"],
dpt_sub=t["dpt_sub"],
source_name=t["source_name"],
destination_name=t["destination_name"],
data_secure=t["data_secure"],
)
async def migrate_telegrams(self) -> None:
"""Migrate telegrams from JSON storage to the current store."""
if not isinstance(self.store, BufferedSqliteStore):
return
history_store = Store[Any](
self.hass, version=1, key="knx/telegrams_history.json"
)
json_data = await history_store.async_load()
if json_data is None:
return
_LOGGER.info("Migrating KNX telegram history from JSON to KNX Telegram Store")
if not isinstance(json_data, list):
_LOGGER.warning(
"Unexpected format in KNX telegram history JSON, skipping migration"
)
return
stored_telegrams = [self.dict_to_model(t) for t in json_data]
try:
if stored_telegrams:
await self.store.store_many(stored_telegrams)
_LOGGER.info(
"Successfully migrated %d telegrams", len(stored_telegrams)
)
await history_store.async_remove()
except KnxTelegramStoreException as err:
_LOGGER.error("Database error migrating KNX telegram history: %s", err)
except Exception as err: # noqa: BLE001
_LOGGER.error("Error migrating KNX telegram history: %s", err)
def model_to_dict(self, m: StoredTelegram) -> TelegramDict:
"""Convert a StoredTelegram model to a TelegramDict."""
src_name = m.source_name
if not src_name:
if (device := self.project.devices.get(m.source)) is not None:
src_name = f"{device['manufacturer_name']} {device['name']}"
elif m.direction == TelegramDirection.OUTGOING.value:
src_name = "Home Assistant"
dst_name = m.destination_name
if not dst_name:
if (ga_info := self.project.group_addresses.get(m.destination)) is not None:
dst_name = ga_info.name
dpt_name, unit = self._resolve_dpt(m.dpt_main, m.dpt_sub)
return TelegramDict(
timestamp=m.timestamp.isoformat(),
source=m.source,
destination=m.destination,
direction=m.direction,
telegramtype=m.telegramtype,
payload=m.payload,
value=m.value,
dpt_main=m.dpt_main,
dpt_sub=m.dpt_sub,
dpt_name=dpt_name,
unit=unit,
source_name=src_name,
destination_name=dst_name,
data_secure=m.data_secure,
)
def _resolve_dpt(
self, main: int | None, sub: int | None
) -> tuple[str | None, str | None]:
"""Resolve DPT name and unit from main and sub numbers."""
if main is None:
return None, None
if transcoder := DPTBase.parse_transcoder({"main": main, "sub": sub}):
return transcoder.value_type, transcoder.unit
return None, None
def _serializable_decoded_data(
value: bool | float | str | DPTComplexData | DPTEnumData,
+2 -2
View File
@@ -15,9 +15,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType, VolDictType
from .const import DOMAIN, SIGNAL_KNX_TELEGRAM
from .const import DOMAIN
from .schema import ga_validator
from .telegrams import TelegramDict, decode_telegram_payload
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload
from .validation import dpt_base_type_validator
TRIGGER_TELEGRAM: Final = "telegram"
+11 -124
View File
@@ -2,13 +2,11 @@
from collections.abc import Awaitable, Callable
from contextlib import ExitStack
from datetime import timedelta
from functools import wraps
import inspect
from typing import TYPE_CHECKING, Any, Final, overload
import knx_frontend as knx_panel
from knx_telegram_store import KnxTelegramStoreException, TelegramQuery
import voluptuous as vol
from xknx.telegram import Telegram
from xknxproject.exceptions import XknxProjectException
@@ -18,20 +16,12 @@ from homeassistant.components.frontend import async_panel_exists
from homeassistant.components.http import StaticPathConfig
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util import dt as dt_util
from homeassistant.util.ulid import ulid_now
from .const import (
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
DOMAIN,
KNX_MODULE_KEY,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
SUPPORTED_PLATFORMS_UI,
)
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
from .dpt import get_supported_dpts
from .storage.config_store import ConfigStoreException
from .storage.const import CONF_DATA
@@ -47,7 +37,11 @@ from .storage.entity_store_validation import (
from .storage.expose_controller import validate_expose_data
from .storage.serialize import get_serialized_schema
from .storage.time_server import validate_time_server_data
from .telegrams import TelegramDict
from .telegrams import (
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
TelegramDict,
)
if TYPE_CHECKING:
from .knx_module import KNXModule
@@ -62,7 +56,6 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_project_file_remove)
websocket_api.async_register_command(hass, ws_group_monitor_info)
websocket_api.async_register_command(hass, ws_group_telegrams)
websocket_api.async_register_command(hass, ws_query_telegrams)
websocket_api.async_register_command(hass, ws_subscribe_telegram)
websocket_api.async_register_command(hass, ws_get_knx_project)
websocket_api.async_register_command(hass, ws_validate_entity)
@@ -199,15 +192,6 @@ def ws_get_base_data(
"version": knx.xknx.version,
"connected": knx.xknx.connection_manager.connected.is_set(),
"current_address": str(knx.xknx.current_address),
"telegram_backend": (
"sqlite" if knx.telegrams.store is not None else "unknown"
),
"telegram_retention": knx.telegrams.store.retention_days
if knx.telegrams.store is not None
else None,
"telegram_max_count": knx.telegrams.store.max_telegrams
if knx.telegrams.store is not None
else None,
}
connection.send_result(
@@ -301,44 +285,21 @@ async def ws_project_file_remove(
vol.Required("type"): "knx/group_monitor_info",
}
)
@websocket_api.async_response
@provide_knx
async def ws_group_monitor_info(
@callback
def ws_group_monitor_info(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle get info command of group monitor."""
load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS]
start_time = dt_util.now() - timedelta(hours=load_hours)
query = TelegramQuery(start_time=start_time, order_descending=True)
if knx.telegrams.store is None:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
"Telegram storage backend not initialized. "
"Check logs/Repairs for initialization errors.",
)
return
try:
result = await knx.telegrams.store.query(query, flush_first=True)
except KnxTelegramStoreException as err:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
f"Database error: {err}",
)
return
recent_telegrams = [*knx.telegrams.recent_telegrams]
connection.send_result(
msg["id"],
{
"project_loaded": knx.project.loaded,
"recent_telegrams": [
knx.telegrams.model_to_dict(t) for t in result.telegrams
],
"recent_telegrams": recent_telegrams,
},
)
@@ -364,80 +325,6 @@ def ws_group_telegrams(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/query_telegrams",
vol.Optional("sources"): [str],
vol.Optional("destinations"): [str],
vol.Optional("telegram_types"): [str],
vol.Optional("directions"): [str],
vol.Optional("dpt_mains"): [vol.Coerce(int)],
vol.Optional("start_time"): cv.datetime,
vol.Optional("end_time"): cv.datetime,
vol.Optional("delta_before_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("delta_after_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("limit"): vol.All(vol.Coerce(int), vol.Range(min=1, max=100_000)),
vol.Optional("offset"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("order_descending"): bool,
}
)
@websocket_api.async_response
@provide_knx
async def ws_query_telegrams(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle query telegrams command."""
start_time = msg.get("start_time")
if start_time is None:
load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS]
start_time = dt_util.now() - timedelta(hours=load_hours)
query = TelegramQuery(
sources=msg.get("sources", []),
destinations=msg.get("destinations", []),
telegram_types=msg.get("telegram_types", []),
directions=msg.get("directions", []),
dpt_mains=msg.get("dpt_mains", []),
start_time=start_time,
end_time=msg.get("end_time"),
delta_before_ms=msg.get("delta_before_ms", 0),
delta_after_ms=msg.get("delta_after_ms", 0),
limit=msg.get("limit", 100_000),
offset=msg.get("offset", 0),
order_descending=msg.get("order_descending", True),
)
if knx.telegrams.store is None:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
"Telegram storage backend not initialized. "
"Check logs/Repairs for initialization errors.",
)
return
try:
result = await knx.telegrams.store.query(query, flush_first=True)
except KnxTelegramStoreException as err:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
f"Database error: {err}",
)
return
connection.send_result(
msg["id"],
{
"telegrams": [knx.telegrams.model_to_dict(t) for t in result.telegrams],
"total_count": result.total_count,
"limit_reached": result.limit_reached,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -10,11 +10,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
"requirements": ["python-linkplay==0.2.14"],
"requirements": ["python-linkplay==0.2.12"],
"zeroconf": ["_linkplay._tcp.local."]
}
+3 -3
View File
@@ -169,7 +169,7 @@ class LocalTodoListEntity(TodoListEntity):
await self.async_update_ha_state(force_refresh=True)
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item in the To-do list."""
"""Update an item to the To-do list."""
todo = _convert_item(item)
async with self._calendar_lock:
todo_store = self._new_todo_store()
@@ -179,10 +179,10 @@ class LocalTodoListEntity(TodoListEntity):
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete an item from the To-do list."""
store = self._new_todo_store()
async with self._calendar_lock:
todo_store = self._new_todo_store()
for uid in uids:
todo_store.delete(uid)
store.delete(uid)
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
+1 -1
View File
@@ -23,7 +23,7 @@
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"ip_address": "[%key:common::config_flow::data::ip%]"
}
}
}
@@ -99,7 +99,6 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
# support dry mode.
(0x0001, 0x0108),
(0x0001, 0x010A),
(0x0001, 0x013F),
(0x1209, 0x8000),
(0x1209, 0x8001),
(0x1209, 0x8002),
@@ -139,7 +138,6 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
# support fan-only mode.
(0x0001, 0x0108),
(0x0001, 0x010A),
(0x0001, 0x013F),
(0x118C, 0x2022),
(0x1209, 0x8000),
(0x1209, 0x8001),
+1 -2
View File
@@ -6,7 +6,7 @@ from melnor_bluetooth.device import Device, Valve
from homeassistant.components.number import EntityDescription
from homeassistant.core import callback
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -30,7 +30,6 @@ class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device.mac)},
connections={(CONNECTION_BLUETOOTH, self._device.mac)},
manufacturer="Melnor",
model=self._device.model,
name=self._device.name,
@@ -8,11 +8,6 @@
"bluetooth_confirm": {
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
"title": "Discovered Melnor Bluetooth valve"
},
"pick_device": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
@@ -10,10 +10,10 @@
"step": {
"user": {
"data": {
"station_code": "Station code"
"code": "Station code"
},
"data_description": {
"station_code": "Looks like ESCAT4300000043206B"
"code": "Looks like ESCAT4300000043206B"
}
}
}
+3 -4
View File
@@ -508,20 +508,19 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
solar_save = 9, 34
gentle = 10, 35, 210
extra_quiet = 11, 36, 207
hygiene = 12, 37, 206
quick_power_wash = 13, 38, 216
hygiene = 12, 37
quick_power_wash = 13, 38
pasta_paela = 14
tall_items = 17, 42
glasses_warm = 19
quick_intense = 21
normal = 23, 30, 217
normal = 23, 30
pre_wash = 24
pot_rests_and_filters = 25
power_wash = 44, 204
comfort_wash = 203
comfort_wash_plus = 209
rinse_salt = 215
rinse_and_hold = 219
class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
+2 -2
View File
@@ -135,8 +135,6 @@ class MieleFan(MieleEntity, FanEntity):
_LOGGER.debug("Calc ventilation_step: %s", ventilation_step)
if ventilation_step == 0:
await self.async_turn_off()
elif ventilation_step == self.device.state_ventilation_step:
return
else:
try:
await self.api.send_action(
@@ -167,6 +165,7 @@ class MieleFan(MieleEntity, FanEntity):
try:
await self.api.send_action(self._device_id, {POWER_ON: True})
except ClientResponseError as ex:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
@@ -184,6 +183,7 @@ class MieleFan(MieleEntity, FanEntity):
try:
await self.api.send_action(self._device_id, {POWER_OFF: True})
except ClientResponseError as ex:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
@@ -791,7 +791,6 @@
"rice_pudding_rapid_steam_cooking": "Rice pudding (rapid steam cooking)",
"rice_pudding_steam_cooking": "Rice pudding (steam cooking)",
"rinse": "Rinse",
"rinse_and_hold": "Rinse and hold",
"rinse_out_lint": "Rinse out lint",
"rinse_salt": "Rinse salt",
"risotto": "Risotto",
@@ -10,9 +10,6 @@
},
"step": {
"confirm": {
"data": {
"blind_type": "Blind type"
},
"description": "What kind of blind is {display_name}?"
},
"user": {
+8 -11
View File
@@ -1332,17 +1332,14 @@ class MQTT:
msg.payload[0:8192],
)
return
if _LOGGER.isEnabledFor(logging.DEBUG):
# Guard the debug log so the payload is not sliced (copied) on
# every received message when debug logging is disabled.
_LOGGER.debug(
"Received%s message on %s (qos=%s) IDs=%s: %s",
" retained" if msg.retain else "",
topic,
msg.qos,
identifiers,
msg.payload[0:8192],
)
_LOGGER.debug(
"Received%s message on %s (qos=%s) IDs=%s: %s",
" retained" if msg.retain else "",
topic,
msg.qos,
identifiers,
msg.payload[0:8192],
)
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
for subscription in self._matching_subscriptions(topic, identifiers):
+1 -2
View File
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
@@ -57,7 +57,6 @@ class MyStromLight(LightEntity):
self._attr_hs_color = 0, 0
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, mac)},
name=name,
manufacturer=MANUFACTURER,
sw_version=self._bulb.firmware,
+10 -12
View File
@@ -50,16 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
)
if not await webio_api.refresh_device_info():
_LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST])
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_internal_error",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
webio_serial = webio_api.get_serial_number()
if webio_serial is None:
_LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST])
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_internal_error",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
@@ -67,10 +67,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
_LOGGER.error(
"[%s] Serial number doesn't match config entry", entry.data[CONF_HOST]
)
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_serial_mismatch",
)
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(translation_key="config_entry_error_serial_mismatch")
coordinator = NASwebCoordinator(
hass, webio_api, name=f"NASweb[{webio_api.get_name()}]"
@@ -81,15 +79,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
webhook_url = nasweb_data.get_webhook_url(hass)
if not await webio_api.status_subscription(webhook_url, True):
_LOGGER.error("Failed to subscribe for status updates from webio")
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_internal_error",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
if not await nasweb_data.notify_coordinator.check_connection(webio_serial):
_LOGGER.error("Did not receive status from device")
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_no_status_update",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
@@ -98,14 +96,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
f"[{entry.data[CONF_HOST]}] Check connection reached timeout"
) from error
except AuthError as error:
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_invalid_authentication",
translation_key="config_entry_error_invalid_authentication"
) from error
except NoURLAvailableError as error:
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_missing_internal_url",
translation_key="config_entry_error_missing_internal_url"
) from error
device_registry = dr.async_get(hass)
+1 -1
View File
@@ -65,7 +65,7 @@
"config_entry_error_no_status_update": {
"message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}"
},
"config_entry_error_serial_mismatch": {
"serial_mismatch": {
"message": "Connected to different NASweb device (serial number mismatch)."
}
}
@@ -26,7 +26,6 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_when_setup
from homeassistant.util import slugify
from homeassistant.util.json import json_loads
from .config_flow import CONF_SECRET
@@ -312,6 +311,6 @@ class OwnTracksContext:
# kwargs location is the beacon's configured lat/lon
kwargs.pop("battery", None)
for beacon in self.mobile_beacons_active[dev_id]:
kwargs["dev_id"] = slugify(f"{BEACON_DEV_ID}_{beacon}")
kwargs["dev_id"] = f"{BEACON_DEV_ID}_{beacon}"
kwargs["host_name"] = beacon
self.async_see(**kwargs)
+1 -1
View File
@@ -41,7 +41,7 @@
"step": {
"user": {
"data": {
"scan_interval": "Update interval (minutes)"
"update_interval": "Update interval (minutes)"
},
"description": "Set the update interval (minutes)",
"title": "Options for Plaato"
@@ -90,5 +90,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.4.0"]
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.2.0"]
}
+1 -1
View File
@@ -14,5 +14,5 @@
"cloudapp/QBUSMQTTGW/+/state"
],
"quality_scale": "bronze",
"requirements": ["qbusmqttapi==1.5.1"]
"requirements": ["qbusmqttapi==1.5.0"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==4.2.0"]
"requirements": ["rf-protocols==4.1.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.3.1"]
"requirements": ["pyrainbird==6.3.0"]
}
@@ -34,7 +34,11 @@ rules:
docs-removal-instructions: todo
test-before-setup: done
docs-high-level-description: done
config-flow-test-coverage: done
config-flow-test-coverage:
status: todo
comment: |
All config flow tests should finish with CREATE_ENTRY and ABORT to
test they are able to recover from errors
docs-actions: done
runtime-data: done
@@ -20,5 +20,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.21.1"]
"requirements": ["reolink-aio==0.21.0"]
}
+1 -6
View File
@@ -19,11 +19,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import (
@@ -181,7 +177,6 @@ class RingBaseEntity(
self._attr_extra_state_attributes = {}
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)}, # device_id is the mac
connections={(CONNECTION_NETWORK_MAC, format_mac(device.device_id))},
manufacturer="Ring",
model=device.model,
name=device.name,
@@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/scrape",
"iot_class": "cloud_polling",
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.1.1"]
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.1"]
}
+2 -1
View File
@@ -8,7 +8,6 @@ from propcache.api import cached_property
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_OPTION, SERVICE_SELECT_OPTION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
@@ -19,11 +18,13 @@ from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_CYCLE,
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN,
SERVICE_SELECT_FIRST,
SERVICE_SELECT_LAST,
SERVICE_SELECT_NEXT,
SERVICE_SELECT_OPTION,
SERVICE_SELECT_PREVIOUS,
)
+4
View File
@@ -4,6 +4,8 @@ DOMAIN = "select"
ATTR_CYCLE = "cycle"
ATTR_OPTIONS = "options"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_OPTION = "option"
CONF_CYCLE = "cycle"
CONF_OPTION = "option"
@@ -11,4 +13,6 @@ CONF_OPTION = "option"
SERVICE_SELECT_FIRST = "select_first"
SERVICE_SELECT_LAST = "select_last"
SERVICE_SELECT_NEXT = "select_next"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_SELECT_OPTION = "select_option"
SERVICE_SELECT_PREVIOUS = "select_previous"
@@ -10,12 +10,10 @@ from homeassistant.components.device_automation import (
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_OPTION,
CONF_DEVICE_ID,
CONF_DOMAIN,
CONF_ENTITY_ID,
CONF_TYPE,
SERVICE_SELECT_OPTION,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -25,6 +23,7 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import (
ATTR_CYCLE,
ATTR_OPTION,
ATTR_OPTIONS,
CONF_CYCLE,
CONF_OPTION,
@@ -32,6 +31,7 @@ from .const import (
SERVICE_SELECT_FIRST,
SERVICE_SELECT_LAST,
SERVICE_SELECT_NEXT,
SERVICE_SELECT_OPTION,
SERVICE_SELECT_PREVIOUS,
)
@@ -5,10 +5,10 @@ from collections.abc import Iterable
import logging
from typing import Any
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, HomeAssistant, State
from .const import ATTR_OPTIONS, DOMAIN
from .const import ATTR_OPTION, ATTR_OPTIONS, DOMAIN, SERVICE_SELECT_OPTION
_LOGGER = logging.getLogger(__name__)
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["construct", "snapcast"],
"requirements": ["snapcast==2.3.8"]
"requirements": ["snapcast==2.3.7"]
}
+4 -2
View File
@@ -80,10 +80,11 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity):
True,
)
except SnooCommandException as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_on_failed",
translation_placeholders={"name": str(self.name)},
translation_placeholders={"name": str(self.name), "status": "on"},
) from err
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -96,8 +97,9 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity):
False,
)
except SnooCommandException as err:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_off_failed",
translation_placeholders={"name": str(self.name)},
translation_placeholders={"name": str(self.name), "status": "off"},
) from err
+2 -5
View File
@@ -19,7 +19,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -85,10 +85,7 @@ class SnoozFan(FanEntity, RestoreEntity):
"""Initialize a Snooz fan entity."""
self._device = data.device
self._attr_unique_id = data.device.address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device.address)},
connections={(CONNECTION_BLUETOOTH, data.device.address)},
)
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)})
@callback
def _async_write_state_changed(self) -> None:
+1 -2
View File
@@ -18,8 +18,7 @@
},
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]",
"name": "[%key:common::config_flow::data::name%]"
"address": "[%key:common::config_flow::data::device%]"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
@@ -86,11 +86,12 @@ async def async_setup_entry(
},
) from e
except OpendataTransportError as e:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="invalid_data",
translation_placeholders={
"stationboard_url": PLACEHOLDERS["stationboard_url"],
**PLACEHOLDERS,
"config_title": entry.title,
"error": str(e),
},
@@ -96,7 +96,6 @@ PLATFORMS_BY_TYPE = {
],
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.STANDING_FAN.value: [Platform.SENSOR],
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
@@ -208,7 +207,6 @@ CLASS_BY_DEVICE = {
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
SupportedModels.STANDING_FAN.value: switchbot.SwitchbotStandingFan,
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
@@ -71,7 +71,6 @@ class SupportedModels(StrEnum):
LOCK_VISION = "lock_vision"
LOCK_PRO_WIFI = "lock_pro_wifi"
WEATHER_STATION = "weather_station"
STANDING_FAN = "standing_fan"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -121,7 +120,6 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.LOCK_VISION_PRO: SupportedModels.LOCK_VISION_PRO,
SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION,
SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI,
SwitchbotModel.STANDING_FAN: SupportedModels.STANDING_FAN,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
"requirements": ["py-synologydsm-api==2.10.0"],
"requirements": ["py-synologydsm-api==2.9.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Basic:1",
@@ -1201,11 +1201,8 @@
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
@@ -1213,7 +1210,6 @@
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
@@ -1237,7 +1233,6 @@
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
@@ -1262,12 +1257,8 @@
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
@@ -1275,7 +1266,6 @@
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
@@ -1298,8 +1288,6 @@
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"uptime": "[%key:component::sensor::entity_component::uptime::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
@@ -6,15 +6,7 @@ import voluptuous as vol
from voluptuous import All, Range
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ID,
ATTR_LOCATION,
ATTR_NAME,
ATTR_TIME,
CONF_DEVICE_ID,
CONF_LATITUDE,
CONF_LONGITUDE,
)
from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -26,14 +18,20 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
_LOGGER = logging.getLogger(__name__)
# Attributes
# pylint: disable-next=home-assistant-duplicate-const
ATTR_ID = "id"
ATTR_GPS = "gps"
ATTR_TYPE = "type"
ATTR_VALUE = "value"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCATION = "location"
ATTR_LOCALE = "locale"
ATTR_ORDER = "order"
ATTR_TIMESTAMP = "timestamp"
ATTR_FIELDS = "fields"
ATTR_ENABLE = "enable"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_TIME = "time"
ATTR_PIN = "pin"
ATTR_TOU_SETTINGS = "tou_settings"
ATTR_PRECONDITIONING_ENABLED = "preconditioning_enabled"
@@ -46,6 +44,8 @@ ATTR_DAYS_OF_WEEK = "days_of_week"
ATTR_START_TIME = "start_time"
ATTR_END_TIME = "end_time"
ATTR_ONE_TIME = "one_time"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_NAME = "name"
ATTR_PRECONDITION_TIME = "precondition_time"
# Services
+3 -8
View File
@@ -3,7 +3,6 @@
import asyncio
import logging
from aiohttp import ClientError
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -82,8 +81,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
except ClientError as e:
raise ConfigEntryNotReady from e
vehicles: list[TessieVehicleData] = []
for vehicle in state_of_all_vehicles["results"]:
@@ -127,13 +124,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
try:
scopes = await tessie.scopes()
except (TeslaFleetError, ClientError) as e:
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
if Scope.ENERGY_DEVICE_DATA in scopes:
try:
products = (await tessie.products())["response"]
except (TeslaFleetError, ClientError) as e:
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
for product in products:
@@ -157,9 +154,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise ConfigEntryNotReady(getattr(e, "message", str(e))) from e
except ClientError as e:
raise ConfigEntryNotReady from e
raise ConfigEntryNotReady(e.message) from e
powerwall = (
product["components"]["battery"] or product["components"]["solar"]
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["steamloop"],
"quality_scale": "bronze",
"requirements": ["steamloop==1.2.1"]
"requirements": ["steamloop==1.2.0"]
}
-1
View File
@@ -711,7 +711,6 @@ class DPCode(StrEnum):
ELECTRICITY_LEFT = "electricity_left"
EXCRETION_TIME_DAY = "excretion_time_day"
EXCRETION_TIMES_DAY = "excretion_times_day"
EXT_TEMP = "ext_temp"
FACTORY_RESET = "factory_reset"
FAN_BEEP = "fan_beep" # Sound
FAN_COOL = "fan_cool" # Cool wind
-6
View File
@@ -1350,12 +1350,6 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.EXT_TEMP,
translation_key="temperature_external",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -15,9 +15,6 @@
"description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app."
},
"scan": {
"data": {
"QR": "QR code"
},
"description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app."
},
"user": {

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