mirror of
https://github.com/home-assistant/core.git
synced 2026-06-13 20:52:08 +02:00
Compare commits
22 Commits
yoto_sensors
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d175e452 | |||
| 88f1cb55d4 | |||
| 2972d9eaa5 | |||
| a9de180937 | |||
| 7a898c0eca | |||
| d3d883358c | |||
| 483f7072dd | |||
| 2db3a5024b | |||
| 0b870e104f | |||
| c5acc04860 | |||
| a1486af33a | |||
| 527c0b1fb8 | |||
| d284dff5ce | |||
| 3fbdb88b3c | |||
| 9957393f91 | |||
| 95e6c39e40 | |||
| 54b6c5c542 | |||
| 065cb7abcb | |||
| 120cc2af6a | |||
| 7dd7bae231 | |||
| f0c0e937d1 | |||
| fcf9e6be63 |
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"time": {
|
||||
"day_mode_start": {
|
||||
"default": "mdi:weather-sunny"
|
||||
},
|
||||
"night_mode_start": {
|
||||
"default": "mdi:weather-night"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
},
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
|
||||
|
||||
Generated
+8
-5
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user