Compare commits

...

22 Commits

Author SHA1 Message Date
jasonjhofmann 34d175e452 Add Bluetooth connection to Melnor devices (#173669)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:27:19 +02:00
jasonjhofmann 88f1cb55d4 Add Bluetooth connection to Snooz devices (#173668)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:26:55 +02:00
jasonjhofmann 2972d9eaa5 Add Bluetooth connection to Eurotronic Comet Blue devices (#173670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:24:02 +02:00
Paul Bottein a9de180937 Bump yoto-api to 4.2.1 (#173699) 2026-06-13 20:23:46 +02:00
Paul Bottein 7a898c0eca Add time platform to Yoto (#173617) 2026-06-13 20:23:09 +02:00
A. Gideonse d3d883358c Add optimistic updates for Indevolt (#173091) 2026-06-13 20:20:37 +02:00
karwosts 483f7072dd Add missing template device class translations (#173121) 2026-06-13 18:18:49 +02:00
James Myatt 2db3a5024b Clean up local todo doc strings, locking, and test style (#173461) 2026-06-13 08:43:33 -07:00
Franck Nijhof 0b870e104f Avoid slicing MQTT payload for debug log on every received message (#173693) 2026-06-13 16:24:16 +02:00
Åke Strandberg c5acc04860 Add missing Miele dishwasher codes (#173662) 2026-06-13 14:53:54 +02:00
epenet a1486af33a Add ext_temp as datapoint for Tuya wsdcg category (#173366) 2026-06-13 14:45:00 +02:00
jasonjhofmann 527c0b1fb8 Add network MAC connection to Ring devices (#173671)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:13:05 -04:00
Franck Nijhof d284dff5ce Precompile entity service schemas to avoid per-call recompilation (#173685) 2026-06-13 08:11:11 -04:00
Martin Hoefling 3fbdb88b3c Migrate to knx telegram store (#169700)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthias Alphart <farmio@alphart.net>
2026-06-13 07:43:48 -04:00
Franck Nijhof 9957393f91 Fix workday entity triggering updates while disabled (#173626) 2026-06-13 13:39:02 +02:00
Raphael Hehl 95e6c39e40 Bump uiprotect to 13.1.1 (#173584) 2026-06-13 09:52:11 +02:00
renovate[bot] 54b6c5c542 Update rf-protocols to 4.2.0 (#173650) 2026-06-13 09:50:22 +02:00
torben-iometer 065cb7abcb iometer dependency update to 1.0.2 (#173608) 2026-06-13 09:41:43 +02:00
Matthias Alphart 120cc2af6a Update wording of knx data secure repair issue (#173591) 2026-06-13 09:28:21 +02:00
kingy444 7dd7bae231 Fix available status for Powerview tilt only shades (#173655) 2026-06-13 07:07:21 +02:00
renovate[bot] f0c0e937d1 Update hassil to 3.8.0 (#173649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-12 22:23:14 -05:00
Franck Nijhof fcf9e6be63 Fix Withings activity sensors using wrong timezone for date comparison (#173640) 2026-06-12 22:36:13 +02:00
75 changed files with 2552 additions and 375 deletions
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0"]
"requirements": ["hassil==3.8.0"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.7.0", "home-assistant-intents==2026.6.1"]
"requirements": ["hassil==3.8.0", "home-assistant-intents==2026.6.1"]
}
@@ -57,6 +57,7 @@ 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"],
@@ -536,6 +536,11 @@ class PowerViewShadeTiltOnly(PowerViewShadeWithTiltBase):
"""Return if the cover is closed."""
return self.positions.tilt <= CLOSED_POSITION
@property
def available(self) -> bool:
"""Return True if shade position data is available."""
return super().available and self.positions.tilt is not None
class PowerViewShadeTopDown(PowerViewShadeBase):
"""Representation of a shade that lowers from the roof to the floor.
@@ -11,6 +11,7 @@ from indevolt_api import (
IndevoltConfig,
IndevoltEnergyMode,
IndevoltRealtimeAction,
IndevoltRealtimeState,
)
from homeassistant.config_entries import ConfigEntry
@@ -109,6 +110,10 @@ 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:
@@ -142,7 +147,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if refresh:
await self.async_request_refresh()
self.async_optimistic_update(
IndevoltConfig.READ_ENERGY_MODE, target_mode
)
async def async_realtime_action(
self,
@@ -161,10 +168,15 @@ 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(
@@ -172,7 +184,15 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_key="failed_to_execute_realtime_action",
)
await self.async_request_refresh()
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,
}
)
def get_emergency_soc(self) -> int:
"""Get the emergency SOC value."""
+3 -1
View File
@@ -136,7 +136,9 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, int_value
)
else:
raise HomeAssistantError(
+3 -1
View File
@@ -106,7 +106,9 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, value
)
else:
raise HomeAssistantError(
+1 -1
View File
@@ -86,7 +86,7 @@ SENSORS: Final = (
),
# Real-time control state
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_COMMAND,
key=IndevoltConfig.READ_REALTIME_STATE,
translation_key="realtime_command",
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
device_class=SensorDeviceClass.ENUM,
+8 -1
View File
@@ -126,7 +126,14 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
)
if success:
await self.coordinator.async_request_refresh()
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
)
else:
raise HomeAssistantError(
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==1.0.1"],
"requirements": ["iometer==1.0.2"],
"zeroconf": ["_iometer._tcp.local."]
}
+56 -2
View File
@@ -1,6 +1,7 @@
"""The KNX integration."""
import contextlib
import logging
from pathlib import Path
from typing import Final
@@ -17,11 +18,20 @@ 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,
)
@@ -51,11 +61,12 @@ 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(
@@ -147,6 +158,44 @@ 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
@@ -203,7 +252,12 @@ 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 / TELEGRAMS_STORAGE_KEY).unlink()
(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()
with contextlib.suppress(FileNotFoundError, OSError):
(storage_dir / DOMAIN).rmdir()
+71 -37
View File
@@ -16,6 +16,7 @@ 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,
@@ -48,7 +49,8 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
@@ -56,9 +58,10 @@ from .const import (
DEFAULT_ROUTING_IA,
DOMAIN,
KNX_MODULE_KEY,
TELEGRAM_LOG_DEFAULT,
TELEGRAM_LOG_MAX,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
KNXConfigEntryData,
KNXConfigEntryOptions,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .validation import ia_validator, ip_v4_validator
@@ -71,14 +74,20 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData(
local_ip=None,
multicast_group=DEFAULT_MCAST_GRP,
multicast_port=DEFAULT_MCAST_PORT,
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
route_back=False,
)
DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions(
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
telegram_log_size=TELEGRAM_LOG_DEFAULT,
telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT,
telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_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)",
@@ -103,7 +112,7 @@ _PORT_SELECTOR = vol.All(
class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a KNX config flow."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize KNX config flow."""
@@ -184,6 +193,7 @@ 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(
@@ -916,17 +926,16 @@ class KNXOptionsFlow(OptionsFlowWithReload):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize KNX options flow."""
self.initial_data = dict(config_entry.data)
self.initial_options = dict(config_entry.options)
self.new_entry_options: KNXConfigEntryOptions = {}
@callback
def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult:
def finish_flow(self) -> ConfigFlowResult:
"""Update the ConfigEntry and finish the flow."""
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=self.initial_options | self.new_entry_options,
)
return self.async_create_entry(title="", data={})
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -939,24 +948,29 @@ class KNXOptionsFlow(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage KNX communication settings."""
if user_input is not None:
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],
)
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()
data_schema = {
vol.Required(
CONF_KNX_STATE_UPDATER,
default=self.initial_data.get(
default=self.initial_options.get(
CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER
),
): selector.BooleanSelector(),
vol.Required(
CONF_KNX_RATE_LIMIT,
default=self.initial_data.get(
default=self.initial_options.get(
CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT
),
): vol.All(
@@ -969,27 +983,47 @@ class KNXOptionsFlow(OptionsFlowWithReload):
),
vol.Coerce(int),
),
vol.Required(
CONF_KNX_TELEGRAM_LOG_SIZE,
default=self.initial_data.get(
CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT
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.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}",
},
)
+23 -6
View File
@@ -10,9 +10,11 @@ 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)
@@ -50,9 +52,18 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
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
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"
)
##
# Secure constants
@@ -94,10 +105,11 @@ 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 for the KNX integration."""
"""Config entry data for the KNX integration."""
connection_type: str
individual_address: str
@@ -116,11 +128,16 @@ class KNXConfigEntryData(TypedDict, total=False):
knxkeys_password: str # not required
backbone_key: str | None # not required
sync_latency_tolerance: int | None # not required
# OptionsFlow only
class KNXConfigEntryOptions(TypedDict, total=False):
"""Config entry options for the KNX integration."""
state_updater: bool # default state updater: True -> expire 60; False -> init
rate_limit: int
# Integration only (not forwarded to xknx)
telegram_log_size: int # not required
telegram_db_retention_days: int
telegram_db_load_hours: int
class ColorTempModes(Enum):
@@ -39,6 +39,9 @@ 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,6 +1,7 @@
"""Base module for the KNX integration."""
import logging
from typing import cast
from xknx import XKNX
from xknx.core import XknxConnectionState
@@ -43,13 +44,12 @@ 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,
TELEGRAM_LOG_DEFAULT,
KNXConfigEntryOptions,
)
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.data[CONF_KNX_STATE_UPDATER]
if self.entry.options[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.data[CONF_KNX_RATE_LIMIT],
rate_limit=self.entry.options[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,
log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT),
config=cast(KNXConfigEntryOptions, entry.options),
)
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.save_history()
await self.telegrams.stop()
def connection_config(self) -> ConnectionConfig:
"""Return the connection_config."""
+2 -1
View File
@@ -13,7 +13,8 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"knx-frontend==2026.6.1.213802"
"knx-frontend==2026.6.1.213802",
"knx-telegram-store[sqlite]==0.3.2"
],
"single_config_entry": true
}
+26 -1
View File
@@ -15,15 +15,17 @@ 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"
@@ -160,3 +162,26 @@ 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,
)
+20 -5
View File
@@ -1123,12 +1123,16 @@
"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%]"
},
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated KNX Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
"description": "Telegrams for group addresses used in Home Assistant could not be decrypted because Data Secure keys are missing or invalid:\n\n{addresses}\n\nTo fix this, update the sending devices configurations via ETS and provide an updated ETS interface information export or Keyring file. Make sure that the group addresses used in Home Assistant are associated with the interface used by Home Assistant (`{interface}` when the issue last occurred).",
"title": "Update KNX Keyring"
}
}
},
"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": {
@@ -1136,13 +1140,24 @@
"communication_settings": {
"data": {
"rate_limit": "Rate limit",
"state_updater": "State updater",
"telegram_log_size": "Telegram history limit"
"state_updater": "State updater"
},
"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.",
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
"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"
}
},
"title": "Communication settings"
}
+264 -37
View File
@@ -1,8 +1,17 @@
"""KNX Telegram handler."""
"""KNX Telegrams history and storage."""
from collections import deque
from typing import Final, TypedDict
import asyncio
import contextlib
from datetime import datetime
import logging
import os
from typing import Any, 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
@@ -10,23 +19,34 @@ from xknx.exceptions import XKNXException
from xknx.telegram import Telegram, TelegramDirection
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.core import HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.storage import STORAGE_DIR, Store
from homeassistant.util import dt as dt_util
from homeassistant.util.signal_type import SignalType
from .const import DOMAIN
from .project import KNXProject
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"
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 .project import KNXProject
from .repairs import (
async_create_telegram_storage_issue,
async_delete_telegram_storage_issue,
)
_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):
@@ -62,14 +82,27 @@ class Telegrams:
hass: HomeAssistant,
xknx: XKNX,
project: KNXProject,
log_size: int,
config: KNXConfigEntryOptions,
) -> None:
"""Initialize Telegrams class."""
self.hass = hass
self.project = project
self._history_store = Store[list[TelegramDict]](
hass, STORAGE_VERSION, STORAGE_KEY
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._xknx_telegram_cb_handle = (
xknx.telegram_queue.register_telegram_received_cb(
telegram_received_cb=self._xknx_telegram_cb,
@@ -81,43 +114,132 @@ 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 (telegrams := await self._history_store.async_load()) is None:
if self._uninitialized_store is None:
return
if self.recent_telegrams.maxlen == 0:
await self._history_store.async_remove()
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()
return
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
}
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
async def save_history(self) -> None:
"""Save history to store."""
if self.recent_telegrams:
await self._history_store.async_save(list(self.recent_telegrams))
# 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)
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)
self.recent_telegrams.append(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_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
)
@@ -168,6 +290,111 @@ 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
from .const import DOMAIN, SIGNAL_KNX_TELEGRAM
from .schema import ga_validator
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload
from .telegrams import TelegramDict, decode_telegram_payload
from .validation import dpt_base_type_validator
TRIGGER_TELEGRAM: Final = "telegram"
+124 -11
View File
@@ -2,11 +2,13 @@
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
@@ -16,12 +18,20 @@ 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 device_registry as dr
from homeassistant.helpers import config_validation as cv, 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 DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
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 .dpt import get_supported_dpts
from .storage.config_store import ConfigStoreException
from .storage.const import CONF_DATA
@@ -37,11 +47,7 @@ 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 (
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
TelegramDict,
)
from .telegrams import TelegramDict
if TYPE_CHECKING:
from .knx_module import KNXModule
@@ -56,6 +62,7 @@ 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)
@@ -192,6 +199,15 @@ 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(
@@ -285,21 +301,44 @@ async def ws_project_file_remove(
vol.Required("type"): "knx/group_monitor_info",
}
)
@websocket_api.async_response
@provide_knx
@callback
def ws_group_monitor_info(
async def ws_group_monitor_info(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle get info command of group monitor."""
recent_telegrams = [*knx.telegrams.recent_telegrams]
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
connection.send_result(
msg["id"],
{
"project_loaded": knx.project.loaded,
"recent_telegrams": recent_telegrams,
"recent_telegrams": [
knx.telegrams.model_to_dict(t) for t in result.telegrams
],
},
)
@@ -325,6 +364,80 @@ 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(
{
+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 to the To-do list."""
"""Update an item in 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:
store.delete(uid)
todo_store.delete(uid)
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
+2 -1
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 DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -30,6 +30,7 @@ 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,
+4 -3
View File
@@ -508,19 +508,20 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
solar_save = 9, 34
gentle = 10, 35, 210
extra_quiet = 11, 36, 207
hygiene = 12, 37
quick_power_wash = 13, 38
hygiene = 12, 37, 206
quick_power_wash = 13, 38, 216
pasta_paela = 14
tall_items = 17, 42
glasses_warm = 19
quick_intense = 21
normal = 23, 30
normal = 23, 30, 217
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):
@@ -791,6 +791,7 @@
"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",
+11 -8
View File
@@ -1332,14 +1332,17 @@ class MQTT:
msg.payload[0:8192],
)
return
_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],
)
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],
)
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
for subscription in self._matching_subscriptions(topic, identifiers):
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["rf-protocols==4.1.0"]
"requirements": ["rf-protocols==4.2.0"]
}
+6 -1
View File
@@ -19,7 +19,11 @@ 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 DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import (
@@ -177,6 +181,7 @@ 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,
+5 -2
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 DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -85,7 +85,10 @@ 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)})
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device.address)},
connections={(CONNECTION_BLUETOOTH, data.device.address)},
)
@callback
def _async_write_state_changed(self) -> None:
@@ -1201,8 +1201,11 @@
"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%]",
@@ -1210,6 +1213,7 @@
"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%]",
@@ -1233,6 +1237,7 @@
"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%]",
@@ -1257,8 +1262,12 @@
"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%]",
@@ -1266,6 +1275,7 @@
"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%]",
@@ -1288,6 +1298,8 @@
"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%]",
+1
View File
@@ -711,6 +711,7 @@ 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,6 +1350,12 @@ 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",
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==12.0.0"]
"requirements": ["uiprotect==13.1.1"]
}
@@ -1,7 +1,7 @@
"""Withings coordinator."""
from abc import abstractmethod
from datetime import date, datetime, timedelta
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from aiowithings import (
@@ -270,7 +270,7 @@ class WithingsActivityDataUpdateCoordinator(
self._last_valid_update
)
today = date.today() # noqa: DTZ011
today = dt_util.now().date()
for activity in activities:
if activity.date == today:
self._previous_data = activity
@@ -86,6 +86,13 @@ class BaseWorkdayEntity(Entity):
"""Set up first update."""
self._update_state_and_setup_listener()
async def async_will_remove_from_hass(self) -> None:
"""Cancel pending listener when entity is removed."""
await super().async_will_remove_from_hass()
if self.unsub:
self.unsub()
self.unsub = None
@abstractmethod
def update_data(self, now: datetime) -> None:
"""Update data."""
+1 -1
View File
@@ -19,7 +19,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.TIME]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
+31 -1
View File
@@ -1,7 +1,10 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from typing import Any
from yoto_api import YotoError, YotoPlayer
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -44,3 +47,30 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
class YotoPlayerEntity(YotoEntity):
"""Base class for entities reflecting live player state over MQTT."""
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and bool(self.player.is_online)
class YotoConfigEntity(YotoEntity):
"""Base class for entities that write player settings over REST."""
async def _async_set_config(self, **fields: Any) -> None:
"""Write player config fields and refresh the local copy."""
client = self.coordinator.client
try:
await client.set_player_config(self._player_id, **fields)
await client.update_player_info(self._player_id)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="config_update_failed",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(client.players)
+12
View File
@@ -0,0 +1,12 @@
{
"entity": {
"time": {
"day_mode_start": {
"default": "mdi:weather-sunny"
},
"night_mode_start": {
"default": "mdi:weather-night"
}
}
}
}
+1 -1
View File
@@ -10,5 +10,5 @@
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==4.2.0"]
"requirements": ["yoto-api==4.2.1"]
}
@@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
from .entity import YotoPlayerEntity
URI_SCHEME = "yoto"
URI_CARD = "card"
@@ -53,7 +53,7 @@ async def async_setup_entry(
)
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
class YotoMediaPlayer(YotoPlayerEntity, MediaPlayerEntity):
"""Representation of a Yoto Player."""
_attr_name = None
@@ -82,11 +82,6 @@ class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.is_online)
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
@@ -57,20 +57,14 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
entity-translations:
status: exempt
comment: The media_player uses the device name; no translatable strings yet.
comment: No noisy or less popular entities; nothing is disabled by default.
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
icon-translations: done
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
@@ -36,6 +36,16 @@
}
}
},
"entity": {
"time": {
"day_mode_start": {
"name": "Day mode start"
},
"night_mode_start": {
"name": "Night mode start"
}
}
},
"exceptions": {
"authentication_failed": {
"message": "Yoto credentials are no longer valid. Please reauthenticate your account."
@@ -46,6 +56,9 @@
"command_failed": {
"message": "Yoto command failed: {error}"
},
"config_update_failed": {
"message": "Failed to update Yoto player settings: {error}"
},
"invalid_media_id": {
"message": "Not a Yoto media identifier: {media_id}"
},
+86
View File
@@ -0,0 +1,86 @@
"""Time platform for the Yoto integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import time
from yoto_api import PlayerConfig, YotoPlayer
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoConfigEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class YotoTimeEntityDescription(TimeEntityDescription):
"""Describes a Yoto time entity.
``config_field`` is the ``set_player_config`` kwarg written on change.
"""
value_fn: Callable[[PlayerConfig], time | None]
config_field: str
TIME_ENTITIES: tuple[YotoTimeEntityDescription, ...] = (
YotoTimeEntityDescription(
key="day_mode_start",
translation_key="day_mode_start",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.day_time,
config_field="day_time",
),
YotoTimeEntityDescription(
key="night_mode_start",
translation_key="night_mode_start",
entity_category=EntityCategory.CONFIG,
value_fn=lambda config: config.night_time,
config_field="night_time",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto time platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoTime(coordinator, player, description)
for player in coordinator.client.players.values()
for description in TIME_ENTITIES
)
class YotoTime(YotoConfigEntity, TimeEntity):
"""Representation of a Yoto player config time."""
entity_description: YotoTimeEntityDescription
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
description: YotoTimeEntityDescription,
) -> None:
"""Initialize the time entity."""
super().__init__(coordinator, player)
self.entity_description = description
self._attr_unique_id = f"{player.id}_{description.key}"
@property
def native_value(self) -> time | None:
"""Return the configured time."""
return self.entity_description.value_fn(self.player.info.config)
async def async_set_value(self, value: time) -> None:
"""Update the configured time."""
await self._async_set_config(**{self.entity_description.config_field: value})
+4 -1
View File
@@ -1414,7 +1414,10 @@ def _make_entity_service_schema(schema: dict, extra: int) -> VolSchemaType:
_HAS_ENTITY_SERVICE_FIELD,
)
setattr(validator, "_entity_service_schema", True) # noqa: B010
return validator
# Wrap in a vol.Schema so the vol.All compiles its sub-validators once,
# instead of re-wrapping them in a new vol.Schema on every validation as a
# top-level vol.All does.
return vol.Schema(validator)
BASE_ENTITY_SCHEMA = _make_entity_service_schema({}, vol.PREVENT_EXTRA)
+1 -1
View File
@@ -37,7 +37,7 @@ go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.3
hass-nabucasa==2.2.0
hassil==3.7.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
home-assistant-frontend==20260527.6
home-assistant-intents==2026.6.1
+2 -2
View File
@@ -25,7 +25,7 @@ cryptography==48.0.0
fnv-hash-fast==2.0.3
ha-ffmpeg==3.2.2
hass-nabucasa==2.2.0
hassil==3.7.0
hassil==3.8.0
home-assistant-bluetooth==2.0.0
home-assistant-intents==2026.6.1
httpx==0.28.1
@@ -47,7 +47,7 @@ python-slugify==8.0.4
PyTurboJPEG==1.8.3
PyYAML==6.0.3
requests==2.34.2
rf-protocols==4.1.0
rf-protocols==4.2.0
securetar==2026.4.1
SQLAlchemy==2.0.50
standard-aifc==3.13.0
+8 -5
View File
@@ -1232,7 +1232,7 @@ hass-splunk==0.1.4
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
hassil==3.7.0
hassil==3.8.0
# homeassistant.components.jewish_calendar
hdate[astral]==1.2.1
@@ -1377,7 +1377,7 @@ insteon-frontend-home-assistant==0.6.2
intellifire4py==4.4.0
# homeassistant.components.iometer
iometer==1.0.1
iometer==1.0.2
# homeassistant.components.iotty
iottycloud==0.3.0
@@ -1431,6 +1431,9 @@ knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2026.6.1.213802
# homeassistant.components.knx
knx-telegram-store[sqlite]==0.3.2
# homeassistant.components.kraken
krakenex==2.2.2
@@ -2902,7 +2905,7 @@ renson-endura-delta==1.7.2
reolink-aio==0.21.0
# homeassistant.components.radio_frequency
rf-protocols==4.1.0
rf-protocols==4.2.0
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -3252,7 +3255,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==12.0.0
uiprotect==13.1.1
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.1
@@ -3436,7 +3439,7 @@ yeelightsunflower==0.0.10
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==4.2.0
yoto-api==4.2.1
# homeassistant.components.youless
youless-api==2.2.0
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'bluetooth',
'aa:bb:cc:dd:ee:ff',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'eurotronic_cometblue',
'aa:bb:cc:dd:ee:ff',
),
}),
'labels': set({
}),
'manufacturer': 'Eurotronic GmbH',
'model': 'Comet Blue',
'model_id': None,
'name': 'Comet Blue aa:bb:cc:dd:ee:ff',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': '0.0.10',
'via_device_id': None,
})
# ---
@@ -0,0 +1,25 @@
"""Test the Eurotronic Comet Blue integration setup."""
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.eurotronic_cometblue.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import FIXTURE_MAC
from .conftest import setup_with_selected_platforms
from tests.common import MockConfigEntry
async def test_device_registry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the Bluetooth connection."""
await setup_with_selected_platforms(hass, mock_config_entry)
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, FIXTURE_MAC)})
assert device_entry == snapshot
+3 -4
View File
@@ -82,10 +82,8 @@ async def test_number_set_values(
# Reset mock call count for this iteration
mock_indevolt.set_data.reset_mock()
# Update mock data to reflect the new value
mock_indevolt.fetch_data.return_value[read_key] = test_value
# Call the service to set the value
fetch_count_before = mock_indevolt.fetch_data.call_count
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
@@ -96,7 +94,8 @@ async def test_number_set_values(
# Verify set_data was called with correct parameters
mock_indevolt.set_data.assert_called_with(write_key, test_value)
# Verify updated state
# Verify state updated optimistically without a new fetch
assert mock_indevolt.fetch_data.call_count == fetch_count_before
assert (state := hass.states.get(entity_id)) is not None
assert int(float(state.state)) == test_value
+3 -6
View File
@@ -62,12 +62,8 @@ async def test_select_option(
# Reset mock call count for this iteration
mock_indevolt.set_data.reset_mock()
# Update mock data to reflect the new value
mock_indevolt.fetch_data.return_value[IndevoltConfig.READ_ENERGY_MODE] = (
expected_value
)
# Attempt to change option
fetch_count_before = mock_indevolt.fetch_data.call_count
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
@@ -80,7 +76,8 @@ async def test_select_option(
IndevoltConfig.WRITE_ENERGY_MODE, expected_value
)
# Verify updated state
# Verify state updated optimistically without a new fetch
assert mock_indevolt.fetch_data.call_count == fetch_count_before
assert (state := hass.states.get("select.cms_sf2000_energy_mode")) is not None
assert state.state == option
@@ -73,6 +73,25 @@ async def test_service_charge_discharge(
else:
mock_indevolt.discharge.assert_called_once_with(power, target_soc)
# Verify sensor states were updated optimistically
expected_rt_command = "charging" if service_name == "charge" else "discharging"
assert (state := hass.states.get("sensor.cms_sf2000_energy_mode")) is not None
assert state.state == "real_time_control"
assert (state := hass.states.get("sensor.cms_sf2000_real_time_mode")) is not None
assert state.state == expected_rt_command
assert (
state := hass.states.get("sensor.cms_sf2000_real_time_target_soc")
) is not None
assert int(float(state.state)) == target_soc
assert (
state := hass.states.get("sensor.cms_sf2000_real_time_power_limit")
) is not None
assert int(float(state.state)) == power
@pytest.mark.parametrize("generation", [1], indirect=True)
@pytest.mark.parametrize(
+6 -8
View File
@@ -83,10 +83,8 @@ async def test_switch_turn_on(
# Reset mock call count for this iteration
mock_indevolt.set_data.reset_mock()
# Update mock data to reflect the new value
mock_indevolt.fetch_data.return_value[read_key] = on_value
# Call the service to turn on
fetch_count_before = mock_indevolt.fetch_data.call_count
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
@@ -97,7 +95,8 @@ async def test_switch_turn_on(
# Verify set_data was called with correct parameters
mock_indevolt.set_data.assert_called_with(write_key, 1)
# Verify updated state
# Verify state updated optimistically without a new fetch
assert mock_indevolt.fetch_data.call_count == fetch_count_before
assert (state := hass.states.get(entity_id)) is not None
assert state.state == STATE_ON
@@ -142,10 +141,8 @@ async def test_switch_turn_off(
# Reset mock call count for this iteration
mock_indevolt.set_data.reset_mock()
# Update mock data to reflect the new value
mock_indevolt.fetch_data.return_value[read_key] = off_value
# Call the service to turn off
fetch_count_before = mock_indevolt.fetch_data.call_count
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
@@ -156,7 +153,8 @@ async def test_switch_turn_off(
# Verify set_data was called with correct parameters
mock_indevolt.set_data.assert_called_with(write_key, 0)
# Verify updated state
# Verify state updated optimistically without a new fetch
assert mock_indevolt.fetch_data.call_count == fetch_count_before
assert (state := hass.states.get(entity_id)) is not None
assert state.state == STATE_OFF
+27 -6
View File
@@ -1,9 +1,12 @@
"""Conftest for the KNX integration."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import DEFAULT, AsyncMock, Mock, patch
from knx_telegram_store import BufferedSqliteStore
import pytest
from xknx import XKNX
from xknx.core import XknxConnectionState, XknxConnectionType
@@ -29,8 +32,12 @@ from homeassistant.components.knx.const import (
CONF_KNX_MCAST_PORT,
CONF_KNX_RATE_LIMIT,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
DEFAULT_ROUTING_IA,
DOMAIN,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
)
from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY
from homeassistant.components.knx.storage.config_store import (
@@ -83,6 +90,7 @@ class KNXTestKit:
state_updater: bool = True,
) -> None:
"""Create the KNX integration."""
# Force an in-memory telegram store will be done via autouse fixture.
async def patch_xknx_start():
"""Patch `xknx.start` for unittests."""
@@ -344,18 +352,19 @@ def mock_config_entry() -> MockConfigEntry:
return MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data={
# homeassistant.components.knx.config_flow.DEFAULT_ENTRY_DATA
# has additional keys - there are installations out there
# without these keys so we test with legacy data
# to ensure backwards compatibility (local_ip, telegram_log_size)
CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC,
CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT,
CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_INDIVIDUAL_ADDRESS: DEFAULT_ROUTING_IA,
},
options={
CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT,
CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
},
)
@@ -430,3 +439,15 @@ async def create_ui_entity(
return entity
return _create_ui_entity
@pytest.fixture(autouse=True)
def mock_knx_telegram_store():
"""Mock knx-telegram-store to always use an in-memory database."""
original_init = BufferedSqliteStore.__init__
def mocked_init(self, db_path: str, *args: Any, **kwargs: Any) -> None:
original_init(self, ":memory:", *args, **kwargs)
with patch.object(BufferedSqliteStore, "__init__", mocked_init):
yield
@@ -0,0 +1,164 @@
{
"data": [
{
"destination": "3/2/100",
"source": "1.0.6",
"direction": "Incoming",
"payload": [0],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:34:45.127015+02:00",
"value": 0,
"source_name": "Source 1",
"destination_name": "Dest 1",
"data_secure": false,
"dpt_main": 5,
"dpt_sub": 1,
"dpt_name": "percent",
"unit": "%"
},
{
"destination": "4/2/101",
"source": "1.0.6",
"direction": "Incoming",
"payload": [255],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:34:45.425139+02:00",
"value": 100,
"source_name": "Source 2",
"destination_name": "Dest 2",
"data_secure": false,
"dpt_main": 5,
"dpt_sub": 1,
"dpt_name": "percent",
"unit": "%"
},
{
"destination": "1/2/11",
"source": "1.0.32",
"direction": "Incoming",
"payload": [7, 158],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:34:53.690501+02:00",
"value": 19.5,
"source_name": "Source 3",
"destination_name": "Dest 3",
"data_secure": false,
"dpt_main": 9,
"dpt_sub": 1,
"dpt_name": "temperature",
"unit": "°C"
},
{
"destination": "3/7/62",
"source": "1.0.255",
"direction": "Incoming",
"payload": null,
"telegramtype": "GroupValueRead",
"timestamp": "2026-05-07T23:35:05.898527+02:00",
"value": null,
"source_name": "Source 4",
"destination_name": "Dest 4",
"data_secure": false,
"dpt_main": null,
"dpt_sub": null,
"dpt_name": null,
"unit": null
},
{
"destination": "1/4/100",
"source": "1.0.45",
"direction": "Incoming",
"payload": 1,
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:35:12.778950+02:00",
"value": "on",
"source_name": "Source 5",
"destination_name": "Dest 5",
"data_secure": false,
"dpt_main": 1,
"dpt_sub": 1,
"dpt_name": "switch",
"unit": null
},
{
"destination": "2/4/61",
"source": "1.0.18",
"direction": "Incoming",
"payload": [0, 100],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:35:28.373347+02:00",
"value": 1.0,
"source_name": "Source 6",
"destination_name": "Dest 6",
"data_secure": false,
"dpt_main": 9,
"dpt_sub": 4,
"dpt_name": "illuminance",
"unit": "lx"
},
{
"destination": "0/6/0",
"source": "1.0.1",
"direction": "Incoming",
"payload": [77, 53, 32, 83, 48, 32, 65, 51, 51, 53, 32, 69, 48, 48],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:35:58.858017+02:00",
"value": "M5 S0 A335 E00",
"source_name": "Source 7",
"destination_name": "Dest 7",
"data_secure": false,
"dpt_main": 16,
"dpt_sub": 0,
"dpt_name": "string",
"unit": null
},
{
"destination": "0/1/100",
"source": "1.0.255",
"direction": "Incoming",
"payload": [4, 176],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:36:05.509221+02:00",
"value": 12.0,
"source_name": "Source 8",
"destination_name": "Dest 8",
"data_secure": false,
"dpt_main": 9,
"dpt_sub": 1,
"dpt_name": "temperature",
"unit": "°C"
},
{
"destination": "4/2/11",
"source": "1.0.31",
"direction": "Incoming",
"payload": [12, 131],
"telegramtype": "GroupValueWrite",
"timestamp": "2026-05-07T23:36:32.343434+02:00",
"value": 23.1,
"source_name": "Source 9",
"destination_name": "Dest 9",
"data_secure": false,
"dpt_main": 9,
"dpt_sub": 1,
"dpt_name": "temperature",
"unit": "°C"
},
{
"destination": "1/2/51",
"source": "1.0.21",
"direction": "Incoming",
"payload": [12, 79],
"telegramtype": "GroupValueResponse",
"timestamp": "2026-05-07T23:36:58.932929+02:00",
"value": 22.06,
"source_name": "Source 10",
"destination_name": "Dest 10",
"data_secure": false,
"dpt_main": 9,
"dpt_sub": 1,
"dpt_name": "temperature",
"unit": "°C"
}
]
}
@@ -6,8 +6,12 @@
'individual_address': '0.0.240',
'multicast_group': '224.0.23.12',
'multicast_port': 3671,
}),
'config_entry_options': dict({
'rate_limit': 0,
'state_updater': True,
'telegram_db_load_hours': 24,
'telegram_db_retention_days': 10,
}),
'config_store': dict({
'entities': dict({
@@ -39,9 +43,13 @@
'knxkeys_password': '**REDACTED**',
'multicast_group': '224.0.23.12',
'multicast_port': 3671,
'user_password': '**REDACTED**',
}),
'config_entry_options': dict({
'rate_limit': 0,
'state_updater': True,
'user_password': '**REDACTED**',
'telegram_db_load_hours': 24,
'telegram_db_retention_days': 10,
}),
'config_store': dict({
'entities': dict({
@@ -67,8 +75,12 @@
'individual_address': '0.0.240',
'multicast_group': '224.0.23.12',
'multicast_port': 3671,
}),
'config_entry_options': dict({
'rate_limit': 0,
'state_updater': True,
'telegram_db_load_hours': 24,
'telegram_db_retention_days': 10,
}),
'config_store': dict({
'entities': dict({
@@ -94,8 +106,12 @@
'individual_address': '0.0.240',
'multicast_group': '224.0.23.12',
'multicast_port': 3671,
}),
'config_entry_options': dict({
'rate_limit': 0,
'state_updater': True,
'telegram_db_load_hours': 24,
'telegram_db_retention_days': 10,
}),
'config_store': dict({
'entities': dict({
+133 -75
View File
@@ -16,14 +16,15 @@ from homeassistant import config_entries
from homeassistant.components.knx.config_flow import (
CONF_KEYRING_FILE,
CONF_KNX_GATEWAY,
CONF_KNX_TELEGRAM_STORE_SECTION,
CONF_KNX_TUNNELING_TYPE,
DEFAULT_ENTRY_DATA,
DEFAULT_ENTRY_OPTIONS,
OPTION_MANUAL_TUNNEL,
)
from homeassistant.components.knx.const import (
CONF_KNX_AUTOMATIC,
CONF_KNX_CONNECTION_TYPE,
CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_INDIVIDUAL_ADDRESS,
CONF_KNX_KNXKEY_FILENAME,
CONF_KNX_KNXKEY_PASSWORD,
@@ -40,12 +41,15 @@ from homeassistant.components.knx.const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
DOMAIN,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
@@ -67,8 +71,18 @@ async def _mock_validate_ip_for_invalid_local(ip_address: str) -> str:
return ip_address
def _assert_mock_entry_data(
mock_entry: MockConfigEntry,
expected_data: dict,
expected_options: dict = DEFAULT_ENTRY_OPTIONS,
) -> None:
"""Assert the config entry stores connection data and options separately."""
assert dict(mock_entry.data) == expected_data
assert dict(mock_entry.options) == expected_options
@pytest.fixture(name="knx_setup")
def fixture_knx_setup():
async def fixture_knx_setup(hass: HomeAssistant):
"""Mock KNX entry setup."""
with (
patch("homeassistant.components.knx.async_setup", return_value=True),
@@ -213,6 +227,7 @@ async def test_routing_setup(
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -288,6 +303,7 @@ async def test_routing_setup_with_local_ip(
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -378,6 +394,7 @@ async def test_routing_secure_manual_setup(
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
assert secure_routing_manual["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -448,6 +465,7 @@ async def test_routing_secure_keyfile(
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.123",
}
assert routing_secure_knxkeys["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -568,6 +586,7 @@ async def test_tunneling_setup_manual(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == title
assert result["data"] == config_entry_data
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -705,6 +724,7 @@ async def test_tunneling_setup_manual_request_description_error(
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -802,6 +822,7 @@ async def test_tunneling_setup_for_local_ip(
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -850,6 +871,7 @@ async def test_tunneling_setup_for_multiple_found_gateways(
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -898,6 +920,7 @@ async def test_tunneling_setup_tcp_endpoint_select_skip(
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -954,6 +977,7 @@ async def test_tunneling_setup_tcp_endpoint_select(
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
assert result["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -1026,18 +1050,21 @@ async def test_form_with_automatic_connection_handling(
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == CONF_KNX_AUTOMATIC.capitalize()
# don't use the DEFAULT_ENTRY_* constants here to check for correct usage of defaults
assert result["data"] == {
# don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults
CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_LOCAL_IP: None,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_RATE_LIMIT: 0,
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
assert result["options"] == {
CONF_KNX_RATE_LIMIT: 0,
CONF_KNX_STATE_UPDATER: True,
CONF_KNX_TELEGRAM_LOG_SIZE: 1000,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: KNX_TELEGRAM_DB_RETENTION_DEFAULT,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
}
knx_setup.assert_called_once()
@@ -1174,6 +1201,7 @@ async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) ->
CONF_KNX_LOCAL_IP: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
assert secure_tunnel_manual["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -1223,6 +1251,7 @@ async def test_configure_secure_knxkeys(hass: HomeAssistant, knx_setup) -> None:
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
}
assert secure_knxkeys["options"] == DEFAULT_ENTRY_OPTIONS
knx_setup.assert_called_once()
@@ -1321,21 +1350,22 @@ async def test_reconfigure_flow_connection_type(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_RATE_LIMIT: 0,
CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
}
_assert_mock_entry_data(
mock_config_entry,
{
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
},
)
async def test_reconfigure_flow_secure_manual_to_keyfile(
@@ -1345,6 +1375,7 @@ async def test_reconfigure_flow_secure_manual_to_keyfile(
mock_config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data={
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
@@ -1359,6 +1390,7 @@ async def test_reconfigure_flow_secure_manual_to_keyfile(
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
},
options=DEFAULT_ENTRY_OPTIONS,
)
gateway = _gateway_descriptor(
"192.168.0.1",
@@ -1425,23 +1457,26 @@ async def test_reconfigure_flow_secure_manual_to_keyfile(
assert secure_knxkeys["type"] is FlowResultType.ABORT
assert secure_knxkeys["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "test",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
}
_assert_mock_entry_data(
mock_config_entry,
{
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "test",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
CONF_HOST: "192.168.0.1",
CONF_PORT: 3675,
CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240",
CONF_KNX_ROUTE_BACK: False,
CONF_KNX_LOCAL_IP: None,
},
)
knx_setup.assert_called_once()
@@ -1450,10 +1485,12 @@ async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None:
mock_config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data={
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING,
},
options=DEFAULT_ENTRY_OPTIONS,
)
gateway = _gateway_descriptor("192.168.0.1", 3676)
mock_config_entry.add_to_hass(hass)
@@ -1490,18 +1527,21 @@ async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None:
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_LOCAL_IP: None,
CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
}
_assert_mock_entry_data(
mock_config_entry,
{
**DEFAULT_ENTRY_DATA,
CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING,
CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP,
CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT,
CONF_KNX_LOCAL_IP: None,
CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4",
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_TUNNEL_ENDPOINT_IA: None,
},
)
knx_setup.assert_called_once()
@@ -1524,7 +1564,9 @@ async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> Non
mock_config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data=start_data,
options=DEFAULT_ENTRY_OPTIONS,
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -1548,13 +1590,16 @@ async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> Non
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
}
_assert_mock_entry_data(
mock_config_entry,
{
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
},
)
knx_setup.assert_called_once()
@@ -1572,7 +1617,9 @@ async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> Non
mock_config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data=start_data,
options=DEFAULT_ENTRY_OPTIONS,
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -1606,17 +1653,20 @@ async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> Non
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
}
_assert_mock_entry_data(
mock_config_entry,
{
**start_data,
CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys",
CONF_KNX_KNXKEY_PASSWORD: "password",
CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1",
CONF_KNX_SECURE_USER_ID: None,
CONF_KNX_SECURE_USER_PASSWORD: None,
CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None,
CONF_KNX_ROUTING_BACKBONE_KEY: None,
CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None,
},
)
knx_setup.assert_called_once()
@@ -1637,16 +1687,24 @@ async def test_options_communication_settings(
user_input={
CONF_KNX_STATE_UPDATER: False,
CONF_KNX_RATE_LIMIT: 40,
CONF_KNX_TELEGRAM_LOG_SIZE: 3000,
CONF_KNX_TELEGRAM_STORE_SECTION: {
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
},
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert not result.get("data")
assert initial_data != dict(mock_config_entry.data)
assert mock_config_entry.data == {
**initial_data,
assert result["data"] == {
CONF_KNX_STATE_UPDATER: False,
CONF_KNX_RATE_LIMIT: 40,
CONF_KNX_TELEGRAM_LOG_SIZE: 3000,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
}
knx_setup.assert_called_once()
assert mock_config_entry.data == initial_data
assert mock_config_entry.options == {
CONF_KNX_STATE_UPDATER: False,
CONF_KNX_RATE_LIMIT: 40,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 30,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
}
assert len(knx_setup.mock_calls) == 2
+78 -1
View File
@@ -38,10 +38,14 @@ from homeassistant.components.knx.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_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
DOMAIN,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
KNXConfigEntryData,
)
from homeassistant.config_entries import ConfigEntryState
@@ -355,8 +359,81 @@ async def test_async_remove_entry(
patch("pathlib.Path.rmdir") as rmdir_mock,
):
assert await hass.config_entries.async_remove(config_entry.entry_id)
assert unlink_mock.call_count == 4
assert unlink_mock.call_count == 6
rmdir_mock.assert_called_once()
assert hass.config_entries.async_entries() == []
assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_async_migrate_entry_v1_to_v2(hass: HomeAssistant) -> None:
"""Test KNX config entry migration from v1 to v2."""
config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=1,
data={
"telegram_log_size": 1000,
"other_setting": "some_value",
CONF_KNX_STATE_UPDATER: True,
CONF_KNX_RATE_LIMIT: 30,
},
)
config_entry.add_to_hass(hass)
assert config_entry.version == 1
with patch("homeassistant.components.knx.async_setup_entry", return_value=True):
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.version == 2
assert "telegram_log_size" not in config_entry.data
assert CONF_KNX_STATE_UPDATER not in config_entry.data
assert CONF_KNX_RATE_LIMIT not in config_entry.data
assert CONF_KNX_TELEGRAM_DB_RETENTION_DAYS not in config_entry.data
assert CONF_KNX_TELEGRAM_DB_LOAD_HOURS not in config_entry.data
assert config_entry.options[CONF_KNX_STATE_UPDATER] is True
assert config_entry.options[CONF_KNX_RATE_LIMIT] == 30
assert (
config_entry.options[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS]
== KNX_TELEGRAM_DB_RETENTION_DEFAULT
)
assert (
config_entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS]
== KNX_TELEGRAM_LOAD_HOURS_DEFAULT
)
assert config_entry.data["other_setting"] == "some_value"
async def test_async_migrate_entry_already_v2(hass: HomeAssistant) -> None:
"""Test that migration does not run if already version 2."""
config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=2,
data={
"other_setting": "some_value",
},
)
config_entry.add_to_hass(hass)
with patch("homeassistant.components.knx.async_setup_entry", return_value=True):
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.version == 2
assert config_entry.data == {"other_setting": "some_value"}
async def test_async_migrate_entry_future_version(hass: HomeAssistant) -> None:
"""Test that migration returns False for future versions."""
config_entry = MockConfigEntry(
title="KNX",
domain=DOMAIN,
version=3,
data={},
)
config_entry.add_to_hass(hass)
with patch("homeassistant.components.knx.async_setup_entry", return_value=True):
assert not await hass.config_entries.async_setup(config_entry.entry_id)
+388 -27
View File
@@ -1,21 +1,30 @@
"""KNX Telegrams Tests."""
from __future__ import annotations
from copy import copy
from datetime import datetime
from typing import Any
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from knx_telegram_store import KnxTelegramStoreException, StoredTelegram, TelegramQuery
import pytest
from homeassistant.components.knx.const import (
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
DOMAIN,
KNX_MODULE_KEY,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
)
from homeassistant.components.knx.telegrams import TelegramDict
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from .conftest import KNXTestKit
from tests.common import async_fire_time_changed, async_load_json_object_fixture
MOCK_TIMESTAMP = "2023-07-02T14:51:24.045162-07:00"
MOCK_TELEGRAMS = [
{
@@ -64,18 +73,18 @@ def assert_telegram_history(telegrams: list[TelegramDict]) -> bool:
assert datetime.fromisoformat(test_telegram["timestamp"])
if isinstance(test_telegram["payload"], tuple):
# JSON encodes tuples to lists
test_telegram["payload"] = list(test_telegram["payload"])
test_telegram["payload"] = list(test_telegram["payload"]) # type: ignore[typeddict-item]
assert test_telegram | {"timestamp": MOCK_TIMESTAMP} == comp_telegram
return True
async def test_store_telegam_history(
async def test_store_telegram_history(
hass: HomeAssistant,
knx: KNXTestKit,
hass_storage: dict[str, Any],
) -> None:
"""Test storing telegram history."""
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
await knx.receive_write("1/3/4", True)
await hass.services.async_call(
@@ -83,42 +92,394 @@ async def test_store_telegam_history(
)
await knx.assert_write("2/2/2", (1, 2, 3, 4))
assert len(hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams) == 2
with pytest.raises(KeyError):
hass_storage["knx/telegrams_history.json"]
# Wait for async store task
await hass.async_block_till_done()
await hass.config_entries.async_unload(knx.mock_config_entry.entry_id)
saved_telegrams = hass_storage["knx/telegrams_history.json"]["data"]
assert assert_telegram_history(saved_telegrams)
# Verify in Memory store
assert telegrams_module.store is not None
result = await telegrams_module.store.query(
TelegramQuery(order_descending=False),
flush_first=True,
)
assert len(result.telegrams) == 2
assert result.telegrams[0].destination == "1/3/4"
assert result.telegrams[1].destination == "2/2/2"
async def test_load_telegam_history(
async def test_store_telegram_history_sqlite(
hass: HomeAssistant,
knx: KNXTestKit,
hass_storage: dict[str, Any],
) -> None:
"""Test telegram history restoration."""
hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS}
"""Test storing telegram history in SQLite."""
await knx.setup_integration()
loaded_telegrams = hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams
assert assert_telegram_history(loaded_telegrams)
# TelegramDict "payload" is a tuple, this shall be restored when loading from JSON
assert isinstance(loaded_telegrams[1]["payload"], tuple)
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
await knx.receive_write("1/3/4", True)
await hass.async_block_till_done()
# Verify in SQLite store
assert telegrams_module.store is not None
result = await telegrams_module.store.query(
TelegramQuery(),
flush_first=True,
)
assert len(result.telegrams) == 1
assert result.telegrams[0].destination == "1/3/4"
async def test_remove_telegam_history(
@pytest.mark.parametrize(
"side_effect",
[
pytest.param(KnxTelegramStoreException("DB init failure"), id="db_error"),
pytest.param(TimeoutError(), id="timeout"),
pytest.param(ValueError("unexpected"), id="generic_error"),
],
)
async def test_store_telegram_history_error_handling(
hass: HomeAssistant,
knx: KNXTestKit,
hass_storage: dict[str, Any],
side_effect: Exception,
) -> None:
"""Test telegram history removal when configured to size 0."""
hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS}
"""Test storage initialization handling for the different failure modes."""
with patch(
"knx_telegram_store.BufferedSqliteStore.initialize",
side_effect=side_effect,
):
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is None
# Check that the repair issue was created
issue_registry = ir.async_get(hass)
issue = issue_registry.async_get_issue(DOMAIN, REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR)
assert issue is not None
async def test_migrate_telegrams_from_json(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test legacy JSON telegram history is migrated into the store."""
fixture = await async_load_json_object_fixture(
hass, "telegrams_history.json", DOMAIN
)
json_telegrams = fixture["data"]
history_key = "knx/telegrams_history.json"
knx.hass_storage[history_key] = {
"version": 1,
"minor_version": 1,
"key": history_key,
"data": json_telegrams,
}
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
result = await telegrams_module.store.query(TelegramQuery(), flush_first=True)
assert result.total_count == len(json_telegrams)
# The legacy JSON store is removed after a successful migration
assert history_key not in knx.hass_storage
@pytest.mark.parametrize(
"side_effect",
[
pytest.param(KnxTelegramStoreException("stop failed"), id="db_error"),
pytest.param(ValueError("unexpected"), id="generic_error"),
],
)
async def test_stop_error_handling(
hass: HomeAssistant,
knx: KNXTestKit,
side_effect: Exception,
) -> None:
"""Test that errors while stopping the store are swallowed."""
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
# An error on stop must not propagate
with patch.object(telegrams_module.store, "stop", side_effect=side_effect):
await telegrams_module.stop()
@pytest.mark.usefixtures("load_knxproj")
async def test_model_to_dict_resolution(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test model_to_dict name resolution and DPT handling."""
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.project.loaded
# Names are resolved from the loaded project when not set on the telegram,
# and an unresolvable DPT yields None name/unit.
from_project = StoredTelegram(
timestamp=dt_util.now(),
source="1.0.0",
destination="0/0/1",
direction="Incoming",
telegramtype="GroupValueWrite",
payload=(1,),
value="on",
value_numeric=None,
dpt_main=999,
dpt_sub=1,
source_name="",
destination_name="",
data_secure=False,
)
result = telegrams_module.model_to_dict(from_project)
assert (
result["source_name"] == "Weinzierl Engineering GmbH KNX IP Router 752 secure"
)
assert result["destination_name"] == "Binary"
assert result["dpt_name"] is None
assert result["unit"] is None
# Outgoing telegram from an unknown source falls back to "Home Assistant".
outgoing = StoredTelegram(
timestamp=dt_util.now(),
source="0.0.0",
destination="1/2/3",
direction="Outgoing",
telegramtype="GroupValueWrite",
payload=(1,),
value="on",
value_numeric=None,
dpt_main=None,
dpt_sub=None,
source_name="",
destination_name="",
data_secure=False,
)
result = telegrams_module.model_to_dict(outgoing)
assert result["source_name"] == "Home Assistant"
# A telegram with DPT info resolves the transcoder name and unit, and
# explicit names are preserved as-is.
with_dpt = StoredTelegram(
timestamp=dt_util.now(),
source="1.0.32",
destination="1/2/11",
direction="Incoming",
telegramtype="GroupValueWrite",
payload=(7, 158),
value=19.5,
value_numeric=19.5,
dpt_main=9,
dpt_sub=1,
source_name="Sensor",
destination_name="Temperature",
data_secure=False,
)
result = telegrams_module.model_to_dict(with_dpt)
assert result["source_name"] == "Sensor"
assert result["destination_name"] == "Temperature"
assert result["dpt_name"] == "temperature"
assert result["unit"] == "°C"
async def test_load_history_needs_migration(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test the schema-migration branch of store initialization."""
with patch(
"knx_telegram_store.BufferedSqliteStore.needs_migration",
return_value=True,
):
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
@pytest.mark.parametrize(
"side_effect",
[
pytest.param(KnxTelegramStoreException("hydrate failed"), id="db_error"),
pytest.param(ValueError("unexpected"), id="generic_error"),
],
)
async def test_load_history_hydrate_error(
hass: HomeAssistant,
knx: KNXTestKit,
side_effect: Exception,
) -> None:
"""Test that errors while hydrating last_ga_telegrams are swallowed."""
with patch(
"knx_telegram_store.BufferedSqliteStore.get_last_unique_telegrams",
side_effect=side_effect,
):
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
assert telegrams_module.last_ga_telegrams == {}
async def test_migrate_telegrams_no_json(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test migration is a no-op when there is no legacy JSON history."""
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
result = await telegrams_module.store.query(TelegramQuery(), flush_first=True)
assert result.total_count == 0
async def test_migrate_telegrams_unexpected_format(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test migration skips an unexpected legacy JSON payload format."""
history_key = "knx/telegrams_history.json"
knx.hass_storage[history_key] = {
"version": 1,
"minor_version": 1,
"key": history_key,
"data": "not a list or dict",
}
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
result = await telegrams_module.store.query(TelegramQuery(), flush_first=True)
assert result.total_count == 0
async def test_migrate_telegrams_store_error(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test migration errors are logged without failing setup."""
history_key = "knx/telegrams_history.json"
knx.hass_storage[history_key] = {
"version": 1,
"minor_version": 1,
"key": history_key,
"data": [
{
"destination": "1/1/1",
"source": "1.0.1",
"direction": "Incoming",
"payload": [1],
"telegramtype": "GroupValueWrite",
"timestamp": MOCK_TIMESTAMP,
"value": 1,
"source_name": "",
"destination_name": "",
"data_secure": False,
"dpt_main": 1,
"dpt_sub": 1,
"dpt_name": "switch",
"unit": None,
}
],
}
with patch(
"knx_telegram_store.BufferedSqliteStore.store_many",
side_effect=KnxTelegramStoreException("write failed"),
):
await knx.setup_integration()
# Setup still succeeds even though migration failed
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
async def test_nightly_eviction_calls_evict_expired(
hass: HomeAssistant,
knx: KNXTestKit,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test expired telegrams are evicted on the nightly 3 AM run."""
await hass.config.async_set_time_zone("UTC")
freezer.move_to("2024-01-01 12:00:00+00:00")
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
with patch.object(
telegrams_module.store,
"evict_expired",
new=AsyncMock(wraps=telegrams_module.store.evict_expired),
) as evict_expired:
# Nothing should happen before 3 AM
freezer.move_to("2024-01-02 02:59:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
evict_expired.assert_not_called()
freezer.move_to("2024-01-02 03:00:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
evict_expired.assert_called_once()
async def test_nightly_eviction_zero_retention_deletes_all(
hass: HomeAssistant,
knx: KNXTestKit,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a retention of 0 days deletes all telegrams on the nightly run."""
await hass.config.async_set_time_zone("UTC")
freezer.move_to("2024-01-01 12:00:00+00:00")
knx.mock_config_entry.add_to_hass(hass)
hass.config_entries.async_update_entry(
knx.mock_config_entry,
data=knx.mock_config_entry.data | {CONF_KNX_TELEGRAM_LOG_SIZE: 0},
options=knx.mock_config_entry.options
| {CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: 0},
)
await knx.setup_integration(add_entry_to_hass=False)
# Store.async_remove() is mocked by hass_storage - check that data was removed.
assert "knx/telegrams_history.json" not in hass_storage
assert not hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
await knx.receive_write("1/3/4", True)
await hass.async_block_till_done()
result = await telegrams_module.store.query(TelegramQuery(), flush_first=True)
assert len(result.telegrams) == 1
freezer.move_to("2024-01-02 03:00:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
result = await telegrams_module.store.query(TelegramQuery(), flush_first=True)
assert len(result.telegrams) == 0
async def test_nightly_eviction_error_handling(
hass: HomeAssistant,
knx: KNXTestKit,
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a store error during nightly eviction is logged and does not raise."""
await hass.config.async_set_time_zone("UTC")
freezer.move_to("2024-01-01 12:00:00+00:00")
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
assert telegrams_module.store is not None
with patch.object(
telegrams_module.store,
"evict_expired",
new=AsyncMock(side_effect=KnxTelegramStoreException("evict failed")),
):
freezer.move_to("2024-01-02 03:00:00+00:00")
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert "Database error evicting expired KNX telegrams" in caplog.text
# Store remains operational after the failed eviction
assert telegrams_module.store is not None
@@ -0,0 +1,88 @@
"""KNX Telegrams Migration Tests."""
from __future__ import annotations
import os
from typing import Any
from knx_telegram_store import TelegramQuery
from homeassistant.components.knx.const import DOMAIN, KNX_MODULE_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .conftest import KNXTestKit
from tests.common import async_load_json_object_fixture
async def test_migrate_telegrams_json_to_sqlite(
hass: HomeAssistant,
knx: KNXTestKit,
) -> None:
"""Test migrating telegrams from legacy JSON to SQLite."""
store = Store[dict[str, Any]](hass, version=1, key="knx/telegrams_history.json")
legacy_path = store.path
legacy_data = await async_load_json_object_fixture(
hass, "telegrams_history.json", DOMAIN
)
# The legacy KNX store saved the telegram list directly, so async_load()
# returns the list. Save the inner list, not the fixture wrapper dict.
await store.async_save(legacy_data["data"])
await knx.setup_integration()
telegrams_module = hass.data[KNX_MODULE_KEY].telegrams
await hass.async_block_till_done()
# Verify migration
assert telegrams_module.store is not None
result = await telegrams_module.store.query(TelegramQuery(order_descending=False))
assert len(result.telegrams) == 10
# Check normalization: [0] -> (0,)
# Note: Backend might return list even if stored as tuple due to JSON serialization
assert result.telegrams[0].destination == "3/2/100"
payload_0 = result.telegrams[0].payload
assert isinstance(payload_0, (list, tuple))
assert tuple(payload_0) == (0,)
# Check normalization: [7, 158] -> (7, 158)
assert result.telegrams[2].destination == "1/2/11"
payload_2 = result.telegrams[2].payload
assert isinstance(payload_2, (list, tuple))
assert tuple(payload_2) == (7, 158)
# Check None payload stays None
assert result.telegrams[3].destination == "3/7/62"
assert result.telegrams[3].payload is None
# Check int payload stays int
assert result.telegrams[4].destination == "1/4/100"
assert result.telegrams[4].payload == 1
# Check long string payload
assert result.telegrams[6].destination == "0/6/0"
payload_6 = result.telegrams[6].payload
assert isinstance(payload_6, (list, tuple))
assert tuple(payload_6) == (
77,
53,
32,
83,
48,
32,
65,
51,
51,
53,
32,
69,
48,
48,
)
# Verify legacy file removal
assert not os.path.exists(legacy_path)
+115 -9
View File
@@ -1,8 +1,11 @@
"""KNX Websocket Tests."""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
from knx_telegram_store import KnxTelegramStoreException
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -205,6 +208,105 @@ async def test_knx_group_monitor_info_command(
assert res["result"]["recent_telegrams"] == []
async def test_knx_query_telegrams_command(
hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator
) -> None:
"""Test knx/query_telegrams command."""
await knx.setup_integration()
client = await hass_ws_client(hass)
# get some telegrams to populate the store
await knx.receive_write("1/1/1", True)
await knx.receive_write("2/2/2", (1, 2, 3))
await knx.receive_write("3/3/3", 0x34)
# wait for async store task; the websocket handler flushes buffered
# telegrams before querying, so no explicit flush is needed here
await hass.async_block_till_done()
# Query all
await client.send_json_auto_id({"type": "knx/query_telegrams"})
res = await client.receive_json()
assert res["success"], res
assert len(res["result"]["telegrams"]) == 3
assert res["result"]["total_count"] == 3
assert res["result"]["limit_reached"] is False
# Query with filter
await client.send_json_auto_id(
{
"type": "knx/query_telegrams",
"destinations": ["1/1/1", "3/3/3"],
}
)
res = await client.receive_json()
assert res["success"], res
assert len(res["result"]["telegrams"]) == 2
assert res["result"]["total_count"] == 2
# Query with limit
await client.send_json_auto_id(
{
"type": "knx/query_telegrams",
"limit": 1,
}
)
res = await client.receive_json()
assert res["success"], res
assert len(res["result"]["telegrams"]) == 1
assert res["result"]["total_count"] == 3
assert res["result"]["limit_reached"] is True
@pytest.mark.parametrize(
"command",
["knx/group_monitor_info", "knx/query_telegrams"],
)
async def test_telegram_store_not_initialized(
hass: HomeAssistant,
knx: KNXTestKit,
hass_ws_client: WebSocketGenerator,
command: str,
) -> None:
"""Test telegram commands when the storage backend failed to initialize."""
# Force initialization to fail so the store stays uninitialized (None)
with patch(
"knx_telegram_store.BufferedSqliteStore.initialize",
side_effect=KnxTelegramStoreException("init failed"),
):
await knx.setup_integration()
client = await hass_ws_client(hass)
assert hass.data[KNX_MODULE_KEY].telegrams.store is None
await client.send_json_auto_id({"type": command})
res = await client.receive_json()
assert not res["success"]
assert "not initialized" in res["error"]["message"]
@pytest.mark.parametrize(
"command",
["knx/group_monitor_info", "knx/query_telegrams"],
)
async def test_telegram_store_query_database_error(
hass: HomeAssistant,
knx: KNXTestKit,
hass_ws_client: WebSocketGenerator,
command: str,
) -> None:
"""Test telegram commands when the store query raises a database error."""
await knx.setup_integration()
client = await hass_ws_client(hass)
store = hass.data[KNX_MODULE_KEY].telegrams.store
assert store is not None
with patch.object(store, "query", side_effect=KnxTelegramStoreException("boom")):
await client.send_json_auto_id({"type": command})
res = await client.receive_json()
assert not res["success"]
assert "Database error" in res["error"]["message"]
async def test_knx_group_telegrams_command(
hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator
) -> None:
@@ -257,6 +359,10 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams(
)
await knx.assert_write("1/2/4", 1)
# wait for async store task; group_monitor_info flushes buffered telegrams
# before querying, so no explicit flush is needed here
await hass.async_block_till_done()
# connect websocket after telegrams have been sent
client = await hass_ws_client(hass)
await client.send_json_auto_id({"type": "knx/group_monitor_info"})
@@ -266,21 +372,21 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams(
recent_tgs = res["result"]["recent_telegrams"]
assert len(recent_tgs) == 2
# telegrams are sorted from oldest to newest
assert recent_tgs[0]["destination"] == "1/3/4"
# telegrams are sorted from newest to oldest
assert recent_tgs[0]["destination"] == "1/2/4"
assert recent_tgs[0]["payload"] == 1
assert recent_tgs[0]["telegramtype"] == "GroupValueWrite"
assert recent_tgs[0]["source"] == "1.2.3"
assert recent_tgs[0]["direction"] == "Incoming"
assert (
recent_tgs[0]["source"] == "0.0.0"
) # needs to be the IA currently connected to
assert recent_tgs[0]["direction"] == "Outgoing"
assert isinstance(recent_tgs[0]["timestamp"], str)
assert recent_tgs[1]["destination"] == "1/2/4"
assert recent_tgs[1]["destination"] == "1/3/4"
assert recent_tgs[1]["payload"] == 1
assert recent_tgs[1]["telegramtype"] == "GroupValueWrite"
assert (
recent_tgs[1]["source"] == "0.0.0"
) # needs to be the IA currently connected to
assert recent_tgs[1]["direction"] == "Outgoing"
assert recent_tgs[1]["source"] == "1.2.3"
assert recent_tgs[1]["direction"] == "Incoming"
assert isinstance(recent_tgs[1]["timestamp"], str)
+112 -51
View File
@@ -1,6 +1,6 @@
"""Tests for todo platform of local_todo."""
from collections.abc import Awaitable, Callable
from collections.abc import Callable, Coroutine
from datetime import datetime
import textwrap
from typing import Any
@@ -27,11 +27,12 @@ from .conftest import TEST_ENTITY
from tests.typing import WebSocketGenerator
type WsGetItemsType = Callable[[], Coroutine[Any, Any, list[dict[str, str]]]]
type WsMoveItemType = Callable[[str, str | None], Coroutine[Any, Any, dict[str, Any]]]
@pytest.fixture
async def ws_get_items(
hass_ws_client: WebSocketGenerator,
) -> Callable[[], Awaitable[dict[str, str]]]:
async def ws_get_items(hass_ws_client: WebSocketGenerator) -> WsGetItemsType:
"""Fixture to fetch items from the todo websocket."""
async def get() -> list[dict[str, str]]:
@@ -51,12 +52,10 @@ async def ws_get_items(
@pytest.fixture
async def ws_move_item(
hass_ws_client: WebSocketGenerator,
) -> Callable[[str, str | None], Awaitable[None]]:
async def ws_move_item(hass_ws_client: WebSocketGenerator) -> WsMoveItemType:
"""Fixture to move an item in the todo list."""
async def move(uid: str, previous_uid: str | None) -> None:
async def move(uid: str, previous_uid: str | None) -> dict[str, Any]:
# Fetch items using To-do platform
client = await hass_ws_client()
data = {
@@ -67,15 +66,14 @@ async def ws_move_item(
if previous_uid is not None:
data["previous_uid"] = previous_uid
await client.send_json_auto_id(data)
resp = await client.receive_json()
assert resp.get("success")
return await client.receive_json()
return move
@pytest.fixture(autouse=True)
async def set_time_zone(hass: HomeAssistant) -> None:
"""Set the time zone for the tests that keesp UTC-6 all year round."""
"""Set the time zone for the tests that keeps UTC-6 all year round."""
await hass.config.async_set_time_zone("America/Regina")
@@ -106,7 +104,7 @@ async def test_add_item(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None:
@@ -151,7 +149,7 @@ async def test_add_item(
async def test_remove_item(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None:
@@ -195,7 +193,7 @@ async def test_remove_item(
async def test_bulk_remove(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
) -> None:
"""Test removing multiple todo items."""
for i in range(5):
@@ -269,7 +267,7 @@ EXPECTED_UPDATE_ITEM = {
async def test_update_item(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
expected_state: str,
@@ -396,7 +394,7 @@ async def test_update_item(
async def test_update_existing_field(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
item_data: dict[str, Any],
expected_item_data: dict[str, Any],
) -> None:
@@ -447,7 +445,7 @@ async def test_update_existing_field(
async def test_rename(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
) -> None:
"""Test renaming a todo item."""
@@ -521,8 +519,8 @@ async def test_rename(
async def test_move_item(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_move_item: Callable[[str, str | None], Awaitable[None]],
ws_get_items: WsGetItemsType,
ws_move_item: WsMoveItemType,
src_idx: int,
dst_idx: int | None,
expected_items: list[str],
@@ -544,10 +542,9 @@ async def test_move_item(
assert summaries == ["item 1", "item 2", "item 3", "item 4"]
# Prepare items for moving
previous_uid = None
if dst_idx is not None:
previous_uid = uids[dst_idx]
await ws_move_item(uids[src_idx], previous_uid)
previous_uid = None if dst_idx is None else uids[dst_idx]
resp = await ws_move_item(uids[src_idx], previous_uid)
assert resp.get("success")
items = await ws_get_items()
assert len(items) == 4
@@ -558,22 +555,11 @@ async def test_move_item(
async def test_move_item_unknown(
hass: HomeAssistant,
setup_integration: None,
hass_ws_client: WebSocketGenerator,
ws_move_item: WsMoveItemType,
) -> None:
"""Test moving a todo item that does not exist."""
# Prepare items for moving
client = await hass_ws_client()
data = {
"id": 1,
"type": "todo/item/move",
"entity_id": TEST_ENTITY,
"uid": "unknown",
"previous_uid": "item-2",
}
await client.send_json(data)
resp = await client.receive_json()
assert resp.get("id") == 1
resp = await ws_move_item("unknown", "item-2")
assert not resp.get("success")
assert resp.get("error", {}).get("code") == "failed"
assert "not found in todo list" in resp["error"]["message"]
@@ -582,8 +568,8 @@ async def test_move_item_unknown(
async def test_move_item_previous_unknown(
hass: HomeAssistant,
setup_integration: None,
hass_ws_client: WebSocketGenerator,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
ws_move_item: WsMoveItemType,
) -> None:
"""Test moving a todo item that does not exist."""
@@ -597,18 +583,7 @@ async def test_move_item_previous_unknown(
items = await ws_get_items()
assert len(items) == 1
# Prepare items for moving
client = await hass_ws_client()
data = {
"id": 1,
"type": "todo/item/move",
"entity_id": TEST_ENTITY,
"uid": items[0]["uid"],
"previous_uid": "unknown",
}
await client.send_json(data)
resp = await client.receive_json()
assert resp.get("id") == 1
resp = await ws_move_item(items[0]["uid"], "unknown")
assert not resp.get("success")
assert resp.get("error", {}).get("code") == "failed"
assert "not found in todo list" in resp["error"]["message"]
@@ -739,7 +714,7 @@ async def test_parse_existing_ics(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
setup_integration: None,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
ws_get_items: WsGetItemsType,
snapshot: SnapshotAssertion,
expected_state: str,
) -> None:
@@ -812,3 +787,89 @@ async def test_susbcribe(
assert items[0]["summary"] == "milk"
assert items[0]["status"] == "needs_action"
assert "uid" in items[0]
async def test_reset_item_via_update(
hass: HomeAssistant,
setup_integration: None,
ws_get_items: WsGetItemsType,
) -> None:
"""Test resetting a todo item via update action."""
# Create new item
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
{ATTR_ITEM: "soda"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
# Fetch item
items = await ws_get_items()
assert len(items) == 1
item = items[0]
assert item["summary"] == "soda"
assert item["status"] == "needs_action"
item_uid = item.pop("uid")
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "1"
# Complete item
update_time = datetime(2023, 11, 18, 8, 0, 0, tzinfo=dt_util.UTC)
with freeze_time(update_time):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{ATTR_ITEM: item_uid, ATTR_STATUS: "completed"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
# Verify item is completed
items = await ws_get_items()
assert len(items) == 1
item = items[0]
assert item["summary"] == "soda"
assert "uid" in item
del item["uid"]
assert item == {
**EXPECTED_UPDATE_ITEM,
"status": "completed",
"completed": "2023-11-18T08:00:00+00:00",
}
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "0"
# Reset item
update_time = datetime(2023, 11, 18, 8, 1, 0, tzinfo=dt_util.UTC)
with freeze_time(update_time):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{ATTR_ITEM: item_uid, ATTR_STATUS: "needs_action"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
# Verify item is not completed
items = await ws_get_items()
assert len(items) == 1
item = items[0]
assert item["summary"] == "soda"
assert item.get("completed") is None # automatically unset
assert "uid" in item
del item["uid"]
assert item == {
**EXPECTED_UPDATE_ITEM,
"status": "needs_action",
}
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "1"
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'bluetooth',
'FAKE-ADDRESS-1',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'melnor',
'FAKE-ADDRESS-1',
),
}),
'labels': set({
}),
'manufacturer': 'Melnor',
'model': 'test_model',
'model_id': None,
'name': 'test_melnor',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
+37
View File
@@ -0,0 +1,37 @@
"""Test the Melnor integration setup."""
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.melnor.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .conftest import (
FAKE_ADDRESS_1,
mock_config_entry,
patch_async_ble_device_from_address,
patch_async_register_callback,
patch_melnor_device,
)
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the Bluetooth connection."""
entry = mock_config_entry(hass)
with (
patch_async_ble_device_from_address(),
patch_melnor_device(),
patch_async_register_callback(),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, FAKE_ADDRESS_1)}
)
assert device_entry == snapshot
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'mac',
'aa:cd:ef:98:76:54',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'ring',
'aacdef987654',
),
}),
'labels': set({
}),
'manufacturer': 'Ring',
'model': 'doorbots',
'model_id': None,
'name': 'Front Door',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
+16 -1
View File
@@ -5,6 +5,7 @@ from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import ring
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -20,7 +21,7 @@ from homeassistant.components.ring.coordinator import RingConfigEntry, RingEvent
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_DEVICE_ID, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import MOCK_HARDWARE_ID
@@ -43,6 +44,20 @@ async def test_setup_entry(
assert mock_added_config_entry.state is ConfigEntryState.LOADED
async def test_device_registry(
hass: HomeAssistant,
mock_added_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the network MAC connection."""
# device_id "aacdef987654" is the Front Door doorbell's MAC.
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "aacdef987654")}
)
assert device_entry == snapshot
async def test_setup_entry_device_update(
hass: HomeAssistant,
mock_ring_client,
@@ -0,0 +1,36 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
tuple(
'bluetooth',
'00:00:00:00:AB:CD',
),
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'snooz',
'00:00:00:00:AB:CD',
),
}),
'labels': set({
}),
'manufacturer': None,
'model': None,
'model_id': None,
'name': None,
'name_by_user': None,
'primary_config_entry': None,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
+15
View File
@@ -3,11 +3,13 @@
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.snooz.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import TEST_ADDRESS, TEST_PAIRING_TOKEN, SnoozFixture
@@ -46,6 +48,19 @@ async def test_setup_retries_when_device_not_found(
)
async def test_device_registry(
hass: HomeAssistant,
mock_connected_snooz: SnoozFixture,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device registry entry, including the Bluetooth connection."""
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_ADDRESS)}
)
assert device_entry == snapshot
async def test_removing_entry_cleans_up_connections(
hass: HomeAssistant, mock_connected_snooz: SnoozFixture
) -> None:
@@ -107,7 +107,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'henk Active calories burnt today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'calories',
}),
@@ -169,7 +169,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'henk Active time today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.HOURS: 'h'>,
}),
@@ -733,7 +733,7 @@
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'henk Distance travelled today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
@@ -1000,7 +1000,7 @@
'attributes': ReadOnlyDict({
'device_class': 'distance',
'friendly_name': 'henk Elevation change today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfLength.METERS: 'm'>,
}),
@@ -2043,7 +2043,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'henk Intense activity today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
@@ -2702,7 +2702,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'henk Moderate activity today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
@@ -3576,7 +3576,7 @@
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'henk Soft activity today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
@@ -3739,7 +3739,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'henk Steps today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'steps',
}),
@@ -4035,7 +4035,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'henk Total calories burnt today',
'last_reset': '2023-10-20T00:00:00-07:00',
'last_reset': '2023-10-21T00:00:00-07:00',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'calories',
}),
+21 -5
View File
@@ -26,7 +26,7 @@ from . import (
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.freeze_time("2023-10-21 12:00:00")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
hass: HomeAssistant,
@@ -160,7 +160,7 @@ async def test_activity_sensors_unknown_next_day(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test activity sensors will return unknown the next day."""
freezer.move_to("2023-10-21")
freezer.move_to("2023-10-21 12:00:00")
await setup_integration(hass, polling_config_entry, False)
assert hass.states.get("sensor.henk_steps_today")
@@ -181,7 +181,7 @@ async def test_activity_sensors_same_result_same_day(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test activity sensors will return the same result if old data is updated."""
freezer.move_to("2023-10-21")
freezer.move_to("2023-10-21 12:00:00")
await setup_integration(hass, polling_config_entry, False)
assert hass.states.get("sensor.henk_steps_today").state == "1155"
@@ -202,7 +202,7 @@ async def test_activity_sensors_created_when_existed(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test activity sensors will be added if they existed before."""
freezer.move_to("2023-10-21")
freezer.move_to("2023-10-21 12:00:00")
await setup_integration(hass, polling_config_entry, False)
assert hass.states.get("sensor.henk_steps_today")
@@ -223,7 +223,7 @@ async def test_activity_sensors_created_when_receive_activity_data(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test activity sensors will be added if we receive activity data."""
freezer.move_to("2023-10-21")
freezer.move_to("2023-10-21 12:00:00")
withings.get_activities_in_period.return_value = []
await setup_integration(hass, polling_config_entry, False)
@@ -244,6 +244,22 @@ async def test_activity_sensors_created_when_receive_activity_data(
assert hass.states.get("sensor.henk_steps_today")
async def test_activity_sensors_respect_timezone(
hass: HomeAssistant,
withings: AsyncMock,
polling_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test activity sensors use HA timezone instead of system timezone."""
await hass.config.async_set_time_zone("Asia/Tokyo")
freezer.move_to("2023-10-20T20:00:00+00:00")
await setup_integration(hass, polling_config_entry, False)
state = hass.states.get("sensor.henk_steps_today")
assert state is not None
assert state.state == "1155"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sleep_sensors_created_when_existed(
hass: HomeAssistant,
+33
View File
@@ -1,6 +1,7 @@
"""Tests for calendar platform of Workday integration."""
from datetime import datetime, timedelta
import logging
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -14,6 +15,7 @@ from homeassistant.components.calendar import (
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import TEST_CONFIG_WITH_PROVINCE, init_integration
@@ -90,3 +92,34 @@ async def test_holiday_calendar_entity(
state = hass.states.get("calendar.workday_sensor_de_bw_calendar")
assert state is not None
assert state.state == "off"
async def test_no_update_when_disabled(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a disabled calendar entity does not trigger updates."""
zone = await dt_util.async_get_time_zone("US/Hawaii")
freezer.move_to(datetime(2023, 1, 1, 0, 1, 1, tzinfo=zone))
await init_integration(hass, TEST_CONFIG_WITH_PROVINCE)
entity_id = "calendar.workday_sensor_de_bw_calendar"
state = hass.states.get(entity_id)
assert state is not None
entity_registry.async_update_entity(
entity_id, disabled_by=er.RegistryEntryDisabler.USER
)
await hass.async_block_till_done()
with caplog.at_level(logging.WARNING, logger="homeassistant.helpers.entity"):
freezer.move_to(datetime(2023, 1, 2, 0, 1, 1, tzinfo=zone))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
"incorrectly being triggered for updates while it is disabled"
not in caplog.text
)
+6 -1
View File
@@ -1,7 +1,7 @@
"""Fixtures for the Yoto integration tests."""
from collections.abc import Generator
from datetime import UTC, datetime
from datetime import UTC, datetime, time as dt_time
import time
from unittest.mock import AsyncMock, MagicMock, patch
@@ -14,6 +14,7 @@ from yoto_api import (
Group,
PlaybackEvent,
PlaybackStatus,
PlayerConfig,
PlayerInfo,
Track,
YotoPlayer,
@@ -87,6 +88,10 @@ def _build_player() -> YotoPlayer:
player.info = PlayerInfo(
firmware_version="v2.17.5",
mac="aa:bb:cc:dd:ee:ff",
config=PlayerConfig(
day_time=dt_time(7, 0),
night_time=dt_time(19, 0),
),
)
player.last_event = PlaybackEvent(
player_id=PLAYER_ID,
@@ -0,0 +1,101 @@
# serializer version: 1
# name: test_all_entities[time.nursery_yoto_day_mode_start-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.nursery_yoto_day_mode_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Day mode start',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Day mode start',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'day_mode_start',
'unique_id': 'player-test_day_mode_start',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[time.nursery_yoto_day_mode_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Day mode start',
}),
'context': <ANY>,
'entity_id': 'time.nursery_yoto_day_mode_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '07:00:00',
})
# ---
# name: test_all_entities[time.nursery_yoto_night_mode_start-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'time',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'time.nursery_yoto_night_mode_start',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Night mode start',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Night mode start',
'platform': 'yoto',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'night_mode_start',
'unique_id': 'player-test_night_mode_start',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[time.nursery_yoto_night_mode_start-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Nursery Yoto Night mode start',
}),
'context': <ANY>,
'entity_id': 'time.nursery_yoto_night_mode_start',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '19:00:00',
})
# ---
+4 -3
View File
@@ -1,7 +1,7 @@
"""Tests for the Yoto media player platform."""
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET,
MediaPlayerState,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
@@ -68,7 +68,8 @@ async def test_entity_state(
) -> None:
"""Snapshot the media player entity state."""
freezer.move_to("2026-05-08T12:00:00+00:00")
await setup_integration(hass, mock_config_entry)
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.MEDIA_PLAYER]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
+118
View File
@@ -0,0 +1,118 @@
"""Tests for the Yoto time platform."""
from datetime import time
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from yoto_api import YotoError
from homeassistant.components.time import (
ATTR_TIME,
DOMAIN as TIME_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import PLAYER_ID
from tests.common import MockConfigEntry, snapshot_platform
pytestmark = pytest.mark.usefixtures("setup_credentials")
async def _setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the integration with only the time platform."""
with patch("homeassistant.components.yoto.PLATFORMS", [Platform.TIME]):
await setup_integration(hass, mock_config_entry)
@pytest.mark.usefixtures("mock_yoto_client")
async def test_all_entities(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot every Yoto time entity."""
await _setup(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_available_when_offline(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Config is written over REST, so entities stay available when offline."""
player = next(iter(mock_yoto_client.players.values()))
player.is_online = False
await _setup(hass, mock_config_entry)
state = hass.states.get("time.nursery_yoto_day_mode_start")
assert state is not None
assert state.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
("entity_id", "expected_fields"),
[
pytest.param(
"time.nursery_yoto_day_mode_start",
{"day_time": time(8, 30)},
id="day-mode-start",
),
pytest.param(
"time.nursery_yoto_night_mode_start",
{"night_time": time(8, 30)},
id="night-mode-start",
),
],
)
async def test_set_value(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
entity_id: str,
expected_fields: dict[str, time],
) -> None:
"""Setting a time writes the matching player config field."""
await _setup(hass, mock_config_entry)
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: entity_id, ATTR_TIME: time(8, 30)},
blocking=True,
)
mock_yoto_client.set_player_config.assert_awaited_once_with(
PLAYER_ID, **expected_fields
)
mock_yoto_client.update_player_info.assert_awaited_once_with(PLAYER_ID)
async def test_set_value_failure(
hass: HomeAssistant,
mock_yoto_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""A failed config write raises a Home Assistant error."""
await _setup(hass, mock_config_entry)
mock_yoto_client.set_player_config.side_effect = YotoError("MQTT timeout")
with pytest.raises(
HomeAssistantError, match="Failed to update Yoto player settings"
):
await hass.services.async_call(
TIME_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "time.nursery_yoto_day_mode_start", ATTR_TIME: time(7, 0)},
blocking=True,
)