mirror of
https://github.com/home-assistant/core.git
synced 2026-06-14 21:22:13 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26b0079945 | |||
| 7454f40dd8 | |||
| 26b7d1e32c | |||
| f7342ea9b0 | |||
| 825d99ddaf | |||
| 401fae6bdd | |||
| 5433beeec1 | |||
| af60e248d3 | |||
| 8c452c280f | |||
| 3aec970321 | |||
| 687c91d5f4 | |||
| 377fdceb6c | |||
| 11a4533ccc | |||
| 52b2738b2a | |||
| 3fda722dbb | |||
| e01215da0e | |||
| 6c116cf3e4 | |||
| 8017e802dd | |||
| 501d956b1b | |||
| 8aca342a78 | |||
| bd68e9fbe3 | |||
| b75c839868 | |||
| 742bfb00ff | |||
| 987c19d991 | |||
| b4319c4d0c | |||
| 0fdb3ebed7 | |||
| efa3334616 | |||
| 9ec0f2fe4f | |||
| 9bc5e2b06b | |||
| 46a38cc481 | |||
| a63f2f1d20 | |||
| 744bb6a068 | |||
| d449e3e97b | |||
| 0df379704f | |||
| 4ab7ce04a8 | |||
| 210b08b637 | |||
| f0b448dc6e | |||
| b5a314bf60 | |||
| 741c342749 | |||
| f4d4df9c35 | |||
| bcbdf7b2bb | |||
| b3309ef169 | |||
| caaf5f9715 | |||
| 7ce7de3650 | |||
| 2c14c6be75 | |||
| e020f338ab | |||
| c85c2c4cd3 | |||
| c4e618e990 | |||
| 5efde60d21 | |||
| d9dc10ed81 | |||
| cb6ae03d21 | |||
| 915b78473c | |||
| 559006ba19 | |||
| bad2eed9fe | |||
| 9f1a079688 | |||
| 965a96b957 | |||
| d5791ae8b4 | |||
| 7b561934ea | |||
| cf60690fb7 | |||
| 34d175e452 | |||
| 88f1cb55d4 | |||
| 2972d9eaa5 | |||
| a9de180937 | |||
| 7a898c0eca | |||
| d3d883358c | |||
| 483f7072dd | |||
| 2db3a5024b | |||
| 0b870e104f | |||
| c5acc04860 | |||
| a1486af33a | |||
| 527c0b1fb8 | |||
| d284dff5ce | |||
| 3fbdb88b3c | |||
| 9957393f91 | |||
| 95e6c39e40 | |||
| 54b6c5c542 |
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
"requirements": ["aioacaia==0.1.18"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acmeda",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiopulse"],
|
||||
"requirements": ["aiopulse==0.4.6"]
|
||||
"requirements": ["aiopulse==0.4.7"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==14.0.3"]
|
||||
"requirements": ["aioamazondevices==14.0.4"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["anova_wifi"],
|
||||
"requirements": ["anova-wifi==0.17.0"]
|
||||
"requirements": ["anova-wifi==0.17.1"]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
"description": "The credentials for {username} need to be updated",
|
||||
"title": "Re-authenticate Blink"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
|
||||
ActiveConnection,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
HomeAssistant,
|
||||
@@ -45,7 +45,6 @@ from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from .const import (
|
||||
CONF_EVENT,
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
EVENT_DESCRIPTION,
|
||||
|
||||
@@ -13,9 +13,6 @@ if TYPE_CHECKING:
|
||||
DOMAIN = "calendar"
|
||||
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_EVENT = "event"
|
||||
|
||||
|
||||
class CalendarEntityFeature(IntFlag):
|
||||
"""Supported features of the calendar entity."""
|
||||
|
||||
@@ -8,7 +8,7 @@ from hassil.recognize import RecognizeResult
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.const import MATCH_ALL, SERVICE_RELOAD
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
@@ -53,7 +53,6 @@ from .const import (
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .default_agent import async_setup_default_agent
|
||||
|
||||
@@ -19,8 +19,6 @@ ATTR_AGENT_ID = "agent_id"
|
||||
ATTR_CONVERSATION_ID = "conversation_id"
|
||||
|
||||
SERVICE_PROCESS = "process"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_RELOAD = "reload"
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.3.1",
|
||||
"aiodiscover==3.3.2",
|
||||
"cached-ipaddress==1.1.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"requirements": ["eheimdigital==1.7.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.8"],
|
||||
"requirements": ["pyenphase==2.4.9"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up {model} {id} ({ipaddr})?"
|
||||
},
|
||||
"pick_device": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"data_description_password": "Password for the FRITZ!Box.",
|
||||
"data_description_port": "Leave empty to use the default port.",
|
||||
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
|
||||
"data_description_username": "Username for the FRITZ!Box.",
|
||||
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
|
||||
"data_feature_device_tracking": "Enable network device tracking"
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]",
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
|
||||
@@ -151,6 +151,13 @@ class HolidayCalendarEntity(CalendarEntity):
|
||||
"""Set up first update."""
|
||||
self._update_state_and_setup_listener()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Cancel listener when removing."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self.unsub:
|
||||
self.unsub()
|
||||
self.unsub = None
|
||||
|
||||
def update_event(self, now: datetime) -> CalendarEvent | None:
|
||||
"""Return the next upcoming event."""
|
||||
next_holiday = None
|
||||
|
||||
@@ -27,6 +27,7 @@ from homematicip.device import (
|
||||
PassageDetector,
|
||||
PresenceDetectorIndoor,
|
||||
RoomControlDeviceAnalog,
|
||||
RotaryHandleSensor,
|
||||
SmokeDetector,
|
||||
SoilMoistureSensorInterface,
|
||||
SwitchMeasuring,
|
||||
@@ -166,6 +167,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
|
||||
}
|
||||
|
||||
TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"]
|
||||
WINDOW_STATE_VALUES = ["open", "closed", "tilted"]
|
||||
|
||||
|
||||
def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
@@ -204,6 +206,9 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
|
||||
RoomControlDeviceAnalog: lambda device: [
|
||||
HomematicipTemperatureSensor(hap, device),
|
||||
],
|
||||
RotaryHandleSensor: lambda device: [
|
||||
HomematicipWindowStateSensor(hap, device),
|
||||
],
|
||||
LightSensor: lambda device: [
|
||||
HomematicipIlluminanceSensor(hap, device),
|
||||
],
|
||||
@@ -498,6 +503,24 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
return state_attr
|
||||
|
||||
|
||||
class HomematicipWindowStateSensor(HomematicipGenericEntity, SensorEntity):
|
||||
"""Representation of the HomematicIP rotary handle window state sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_options = WINDOW_STATE_VALUES
|
||||
_attr_translation_key = "window_state"
|
||||
|
||||
def __init__(self, hap: HomematicipHAP, device: RotaryHandleSensor) -> None:
|
||||
"""Initialize the window state sensor."""
|
||||
super().__init__(hap, device, feature_id="window_state")
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state."""
|
||||
window_state = getattr(self._device, "windowState", None)
|
||||
return window_state.lower() if window_state is not None else None
|
||||
|
||||
|
||||
class HomematicipFloorTerminalBlockMechanicChannelValve(
|
||||
HomematicipGenericEntity, SensorEntity
|
||||
):
|
||||
|
||||
@@ -98,6 +98,14 @@
|
||||
"non_neutral": "Non-neutral",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
},
|
||||
"window_state": {
|
||||
"name": "Window state",
|
||||
"state": {
|
||||
"closed": "[%key:common::state::closed%]",
|
||||
"open": "[%key:common::state::open%]",
|
||||
"tilted": "Tilted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,14 +50,12 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
|
||||
translation_key="auth_failed",
|
||||
) from error
|
||||
except HomevoltConnectionError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="communication_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
except HomevoltError as error:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_error",
|
||||
|
||||
@@ -172,10 +172,10 @@
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
"message": "Error communicating with the Homevolt battery: {error}"
|
||||
},
|
||||
"unknown_error": {
|
||||
"message": "[%key:common::config_flow::error::unknown%]"
|
||||
"message": "Unknown error from the Homevolt battery: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"id": "Hue bridge"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Hue bridge."
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from huum.const import SaunaStatus
|
||||
|
||||
from homeassistant.components.number import NumberEntity
|
||||
from homeassistant.components.number import NumberDeviceClass, NumberEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -34,7 +34,9 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
|
||||
"""Representation of a steamer."""
|
||||
|
||||
_attr_translation_key = "humidity"
|
||||
_attr_native_max_value = 10
|
||||
_attr_device_class = NumberDeviceClass.HUMIDITY
|
||||
_attr_native_unit_of_measurement = "%"
|
||||
_attr_native_max_value = 40
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_step = 1
|
||||
|
||||
@@ -47,7 +49,7 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current value."""
|
||||
return self.coordinator.data.target_humidity
|
||||
return self.coordinator.data.humidity
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.const import (
|
||||
CONF_ICON,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_OPTIONS,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
@@ -37,8 +38,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
DOMAIN = "input_select"
|
||||
|
||||
CONF_INITIAL = "initial"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
SERVICE_SET_OPTIONS = "set_options"
|
||||
STORAGE_KEY = DOMAIN
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Do you want to set up Islamic Prayer Times?",
|
||||
"title": "Set up Islamic Prayer Times"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsraelRailConfigEntry) -
|
||||
try:
|
||||
await hass.async_add_executor_job(train_schedule.query, start, destination)
|
||||
except Exception as e:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="request_timeout",
|
||||
|
||||
@@ -65,5 +65,10 @@
|
||||
"name": "Trains +2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"request_timeout": {
|
||||
"message": "Timeout connecting to the Israel Rail API for {config_title}: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from jvcprojector import Command, JvcProjector
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER, NAME
|
||||
@@ -27,8 +27,12 @@ class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]):
|
||||
super().__init__(coordinator, command)
|
||||
|
||||
self._attr_unique_id = coordinator.unique_id
|
||||
# The config entry unique id is the device's formatted MAC address (set
|
||||
# from the projector's MAC in the config flow), so it doubles as the
|
||||
# network MAC connection.
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
|
||||
name=NAME,
|
||||
model=self.device.model,
|
||||
manufacturer=MANUFACTURER,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1129,6 +1129,10 @@
|
||||
}
|
||||
},
|
||||
"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(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"location": {
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["linkplay"],
|
||||
"requirements": ["python-linkplay==0.2.12"],
|
||||
"requirements": ["python-linkplay==0.2.14"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support dry mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x0001, 0x013F),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
(0x1209, 0x8002),
|
||||
@@ -138,6 +139,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support fan-only mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x0001, 0x013F),
|
||||
(0x118C, 0x2022),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
"bluetooth_confirm": {
|
||||
"description": "Do you want to add the Melnor Bluetooth valve `{name}` to Home Assistant?",
|
||||
"title": "Discovered Melnor Bluetooth valve"
|
||||
},
|
||||
"pick_device": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"code": "Station code"
|
||||
"station_code": "Station code"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "Looks like ESCAT4300000043206B"
|
||||
"station_code": "Looks like ESCAT4300000043206B"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -135,6 +135,8 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
_LOGGER.debug("Calc ventilation_step: %s", ventilation_step)
|
||||
if ventilation_step == 0:
|
||||
await self.async_turn_off()
|
||||
elif ventilation_step == self.device.state_ventilation_step:
|
||||
return
|
||||
else:
|
||||
try:
|
||||
await self.api.send_action(
|
||||
@@ -165,7 +167,6 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
try:
|
||||
await self.api.send_action(self._device_id, {POWER_ON: True})
|
||||
except ClientResponseError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
@@ -183,7 +184,6 @@ class MieleFan(MieleEntity, FanEntity):
|
||||
try:
|
||||
await self.api.send_action(self._device_id, {POWER_OFF: True})
|
||||
except ClientResponseError as ex:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_state_error",
|
||||
|
||||
@@ -791,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",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"data": {
|
||||
"blind_type": "Blind type"
|
||||
},
|
||||
"description": "What kind of blind is {display_name}?"
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
@@ -57,6 +57,7 @@ class MyStromLight(LightEntity):
|
||||
self._attr_hs_color = 0, 0
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, mac)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)},
|
||||
name=name,
|
||||
manufacturer=MANUFACTURER,
|
||||
sw_version=self._bulb.firmware,
|
||||
|
||||
@@ -50,16 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
|
||||
)
|
||||
if not await webio_api.refresh_device_info():
|
||||
_LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST])
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_internal_error",
|
||||
translation_placeholders={"support_email": SUPPORT_EMAIL},
|
||||
)
|
||||
webio_serial = webio_api.get_serial_number()
|
||||
if webio_serial is None:
|
||||
_LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST])
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_internal_error",
|
||||
translation_placeholders={"support_email": SUPPORT_EMAIL},
|
||||
)
|
||||
@@ -67,8 +67,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
|
||||
_LOGGER.error(
|
||||
"[%s] Serial number doesn't match config entry", entry.data[CONF_HOST]
|
||||
)
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(translation_key="config_entry_error_serial_mismatch")
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_serial_mismatch",
|
||||
)
|
||||
|
||||
coordinator = NASwebCoordinator(
|
||||
hass, webio_api, name=f"NASweb[{webio_api.get_name()}]"
|
||||
@@ -79,15 +81,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
|
||||
webhook_url = nasweb_data.get_webhook_url(hass)
|
||||
if not await webio_api.status_subscription(webhook_url, True):
|
||||
_LOGGER.error("Failed to subscribe for status updates from webio")
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_internal_error",
|
||||
translation_placeholders={"support_email": SUPPORT_EMAIL},
|
||||
)
|
||||
if not await nasweb_data.notify_coordinator.check_connection(webio_serial):
|
||||
_LOGGER.error("Did not receive status from device")
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_no_status_update",
|
||||
translation_placeholders={"support_email": SUPPORT_EMAIL},
|
||||
)
|
||||
@@ -96,14 +98,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
|
||||
f"[{entry.data[CONF_HOST]}] Check connection reached timeout"
|
||||
) from error
|
||||
except AuthError as error:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_key="config_entry_error_invalid_authentication"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_invalid_authentication",
|
||||
) from error
|
||||
except NoURLAvailableError as error:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_key="config_entry_error_missing_internal_url"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_error_missing_internal_url",
|
||||
) from error
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"config_entry_error_no_status_update": {
|
||||
"message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}"
|
||||
},
|
||||
"serial_mismatch": {
|
||||
"config_entry_error_serial_mismatch": {
|
||||
"message": "Connected to different NASweb device (serial number mismatch)."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.helpers.dispatcher import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.setup import async_when_setup
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .config_flow import CONF_SECRET
|
||||
@@ -311,6 +312,6 @@ class OwnTracksContext:
|
||||
# kwargs location is the beacon's configured lat/lon
|
||||
kwargs.pop("battery", None)
|
||||
for beacon in self.mobile_beacons_active[dev_id]:
|
||||
kwargs["dev_id"] = f"{BEACON_DEV_ID}_{beacon}"
|
||||
kwargs["dev_id"] = slugify(f"{BEACON_DEV_ID}_{beacon}")
|
||||
kwargs["host_name"] = beacon
|
||||
self.async_see(**kwargs)
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"update_interval": "Update interval (minutes)"
|
||||
"scan_interval": "Update interval (minutes)"
|
||||
},
|
||||
"description": "Set the update interval (minutes)",
|
||||
"title": "Options for Plaato"
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"cloudapp/QBUSMQTTGW/+/state"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["qbusmqttapi==1.5.0"]
|
||||
"requirements": ["qbusmqttapi==1.5.1"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ rules:
|
||||
docs-removal-instructions: todo
|
||||
test-before-setup: done
|
||||
docs-high-level-description: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
All config flow tests should finish with CREATE_ENTRY and ABORT to
|
||||
test they are able to recover from errors
|
||||
config-flow-test-coverage: done
|
||||
docs-actions: done
|
||||
runtime-data: done
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.21.0"]
|
||||
"requirements": ["reolink-aio==0.21.1"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/scrape",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.0.1"]
|
||||
"requirements": ["beautifulsoup4==4.13.3", "lxml==6.1.1"]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_OPTION, SERVICE_SELECT_OPTION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -18,13 +19,11 @@ from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_CYCLE,
|
||||
ATTR_OPTION,
|
||||
ATTR_OPTIONS,
|
||||
DOMAIN,
|
||||
SERVICE_SELECT_FIRST,
|
||||
SERVICE_SELECT_LAST,
|
||||
SERVICE_SELECT_NEXT,
|
||||
SERVICE_SELECT_OPTION,
|
||||
SERVICE_SELECT_PREVIOUS,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ DOMAIN = "select"
|
||||
|
||||
ATTR_CYCLE = "cycle"
|
||||
ATTR_OPTIONS = "options"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_OPTION = "option"
|
||||
|
||||
CONF_CYCLE = "cycle"
|
||||
CONF_OPTION = "option"
|
||||
@@ -13,6 +11,4 @@ CONF_OPTION = "option"
|
||||
SERVICE_SELECT_FIRST = "select_first"
|
||||
SERVICE_SELECT_LAST = "select_last"
|
||||
SERVICE_SELECT_NEXT = "select_next"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_SELECT_OPTION = "select_option"
|
||||
SERVICE_SELECT_PREVIOUS = "select_previous"
|
||||
|
||||
@@ -10,10 +10,12 @@ from homeassistant.components.device_automation import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_OPTION,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_TYPE,
|
||||
SERVICE_SELECT_OPTION,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -23,7 +25,6 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from .const import (
|
||||
ATTR_CYCLE,
|
||||
ATTR_OPTION,
|
||||
ATTR_OPTIONS,
|
||||
CONF_CYCLE,
|
||||
CONF_OPTION,
|
||||
@@ -31,7 +32,6 @@ from .const import (
|
||||
SERVICE_SELECT_FIRST,
|
||||
SERVICE_SELECT_LAST,
|
||||
SERVICE_SELECT_NEXT,
|
||||
SERVICE_SELECT_OPTION,
|
||||
SERVICE_SELECT_PREVIOUS,
|
||||
)
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ from collections.abc import Iterable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
|
||||
from .const import ATTR_OPTION, ATTR_OPTIONS, DOMAIN, SERVICE_SELECT_OPTION
|
||||
from .const import ATTR_OPTIONS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["construct", "snapcast"],
|
||||
"requirements": ["snapcast==2.3.7"]
|
||||
"requirements": ["snapcast==2.3.8"]
|
||||
}
|
||||
|
||||
@@ -80,11 +80,10 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity):
|
||||
True,
|
||||
)
|
||||
except SnooCommandException as err:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_on_failed",
|
||||
translation_placeholders={"name": str(self.name), "status": "on"},
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -97,9 +96,8 @@ class SnooSwitch(SnooDescriptionEntity, SwitchEntity):
|
||||
False,
|
||||
)
|
||||
except SnooCommandException as err:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_off_failed",
|
||||
translation_placeholders={"name": str(self.name), "status": "off"},
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
) from err
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
"address": "[%key:common::config_flow::data::device%]",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
|
||||
@@ -86,12 +86,11 @@ async def async_setup_entry(
|
||||
},
|
||||
) from e
|
||||
except OpendataTransportError as e:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_data",
|
||||
translation_placeholders={
|
||||
**PLACEHOLDERS,
|
||||
"stationboard_url": PLACEHOLDERS["stationboard_url"],
|
||||
"config_title": entry.title,
|
||||
"error": str(e),
|
||||
},
|
||||
|
||||
@@ -96,6 +96,7 @@ PLATFORMS_BY_TYPE = {
|
||||
],
|
||||
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
|
||||
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
|
||||
SupportedModels.STANDING_FAN.value: [Platform.SENSOR],
|
||||
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
@@ -207,6 +208,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
|
||||
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
|
||||
SupportedModels.STANDING_FAN.value: switchbot.SwitchbotStandingFan,
|
||||
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
|
||||
@@ -71,6 +71,7 @@ class SupportedModels(StrEnum):
|
||||
LOCK_VISION = "lock_vision"
|
||||
LOCK_PRO_WIFI = "lock_pro_wifi"
|
||||
WEATHER_STATION = "weather_station"
|
||||
STANDING_FAN = "standing_fan"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -120,6 +121,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.LOCK_VISION_PRO: SupportedModels.LOCK_VISION_PRO,
|
||||
SwitchbotModel.LOCK_VISION: SupportedModels.LOCK_VISION,
|
||||
SwitchbotModel.LOCK_PRO_WIFI: SupportedModels.LOCK_PRO_WIFI,
|
||||
SwitchbotModel.STANDING_FAN: SupportedModels.STANDING_FAN,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["synology_dsm"],
|
||||
"requirements": ["py-synologydsm-api==2.9.0"],
|
||||
"requirements": ["py-synologydsm-api==2.10.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:Basic:1",
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -6,7 +6,15 @@ import voluptuous as vol
|
||||
from voluptuous import All, Range
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.const import (
|
||||
ATTR_ID,
|
||||
ATTR_LOCATION,
|
||||
ATTR_NAME,
|
||||
ATTR_TIME,
|
||||
CONF_DEVICE_ID,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -18,20 +26,14 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Attributes
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ID = "id"
|
||||
ATTR_GPS = "gps"
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_VALUE = "value"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCATION = "location"
|
||||
ATTR_LOCALE = "locale"
|
||||
ATTR_ORDER = "order"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_FIELDS = "fields"
|
||||
ATTR_ENABLE = "enable"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_TIME = "time"
|
||||
ATTR_PIN = "pin"
|
||||
ATTR_TOU_SETTINGS = "tou_settings"
|
||||
ATTR_PRECONDITIONING_ENABLED = "preconditioning_enabled"
|
||||
@@ -44,8 +46,6 @@ ATTR_DAYS_OF_WEEK = "days_of_week"
|
||||
ATTR_START_TIME = "start_time"
|
||||
ATTR_END_TIME = "end_time"
|
||||
ATTR_ONE_TIME = "one_time"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_NAME = "name"
|
||||
ATTR_PRECONDITION_TIME = "precondition_time"
|
||||
|
||||
# Services
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.exceptions import (
|
||||
Forbidden,
|
||||
@@ -81,6 +82,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from e
|
||||
except ClientError as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
vehicles: list[TessieVehicleData] = []
|
||||
for vehicle in state_of_all_vehicles["results"]:
|
||||
@@ -124,13 +127,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
|
||||
try:
|
||||
scopes = await tessie.scopes()
|
||||
except TeslaFleetError as e:
|
||||
except (TeslaFleetError, ClientError) as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
if Scope.ENERGY_DEVICE_DATA in scopes:
|
||||
try:
|
||||
products = (await tessie.products())["response"]
|
||||
except TeslaFleetError as e:
|
||||
except (TeslaFleetError, ClientError) as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
for product in products:
|
||||
@@ -154,7 +157,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
|
||||
except (InvalidToken, Forbidden, SubscriptionRequired) as e:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
except TeslaFleetError as e:
|
||||
raise ConfigEntryNotReady(e.message) from e
|
||||
raise ConfigEntryNotReady(getattr(e, "message", str(e))) from e
|
||||
except ClientError as e:
|
||||
raise ConfigEntryNotReady from e
|
||||
|
||||
powerwall = (
|
||||
product["components"]["battery"] or product["components"]["solar"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"description": "The Tuya integration now uses an improved login method. To reauthenticate with your Smart Life or Tuya Smart account, you need to enter your user code.\n\nYou can find this code in the Smart Life app or Tuya Smart app in **Settings** > **Account and Security** screen, and enter the code shown on the **User Code** field. The user code is case-sensitive, please be sure to enter it exactly as shown in the app."
|
||||
},
|
||||
"scan": {
|
||||
"data": {
|
||||
"QR": "QR code"
|
||||
},
|
||||
"description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app."
|
||||
},
|
||||
"user": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==12.0.0"]
|
||||
"requirements": ["uiprotect==13.1.2"]
|
||||
}
|
||||
|
||||
@@ -64,17 +64,14 @@ async def async_setup_entry(
|
||||
try:
|
||||
await manager.login()
|
||||
except VeSyncLoginError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except VeSyncServerError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="server_error"
|
||||
) from err
|
||||
except VeSyncAPIResponseError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="api_response_error"
|
||||
) from err
|
||||
|
||||
@@ -154,6 +154,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_response_error": {
|
||||
"message": "Invalid response from the VeSync API"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"server_error": {
|
||||
"message": "Server error occurred while connecting to VeSync"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"update_devices": {
|
||||
"description": "Adds new VeSync devices to Home Assistant.",
|
||||
|
||||
@@ -67,7 +67,4 @@ class VistapoolLEDPulseButton(VistapoolEntity, ButtonEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
# Optimistically reflect the just-written value so a rapid second press
|
||||
# doesn't read the stale off-state before the Firestore push round-trips.
|
||||
self.coordinator.data.setdefault("light", {})["status"] = 1
|
||||
self.coordinator.async_set_updated_data(self.coordinator.data)
|
||||
self.coordinator.apply_optimistic(_LIGHT_STATUS_PATH, 1)
|
||||
|
||||
@@ -81,3 +81,22 @@ class VistapoolDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def get_value(self, path: str, default: Any = None) -> Any:
|
||||
"""Get nested data using dot-notation path."""
|
||||
return AquariteClient.get_value(self.data, path, default)
|
||||
|
||||
def apply_optimistic(self, value_path: str, value: Any) -> None:
|
||||
"""Reflect a just-written value before the Firestore push round-trips.
|
||||
|
||||
Hayward's cloud takes several seconds to acknowledge a write back
|
||||
through Firestore, which would make the UI feel laggy. Writing into
|
||||
coordinator.data after a successful REST call gives entities instant
|
||||
feedback; the next snapshot from Firestore overwrites it harmlessly.
|
||||
"""
|
||||
keys = value_path.split(".")
|
||||
target: dict[str, Any] = self.data
|
||||
for key in keys[:-1]:
|
||||
child = target.get(key)
|
||||
if not isinstance(child, dict):
|
||||
child = {}
|
||||
target[key] = child
|
||||
target = child
|
||||
target[keys[-1]] = value
|
||||
self.async_set_updated_data(self.data)
|
||||
|
||||
@@ -71,3 +71,4 @@ class VistapoolLight(VistapoolEntity, LightEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
self.coordinator.apply_optimistic(_VALUE_PATH, value)
|
||||
|
||||
@@ -233,3 +233,4 @@ class VistapoolNumber(VistapoolEntity, NumberEntity):
|
||||
translation_key="set_failed",
|
||||
translation_placeholders={"entity": self.entity_id},
|
||||
) from err
|
||||
self.coordinator.apply_optimistic(self.entity_description.value_path, raw)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from pythonxbox.api.provider.people.models import Person
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
@@ -17,12 +16,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XboxConfigEntry
|
||||
from .entity import (
|
||||
XboxBaseEntity,
|
||||
XboxBaseEntityDescription,
|
||||
check_deprecated_entity,
|
||||
profile_pic,
|
||||
)
|
||||
from .entity import XboxBaseEntity, XboxBaseEntityDescription, profile_pic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -31,9 +25,7 @@ class XboxBinarySensor(StrEnum):
|
||||
"""Xbox binary sensor."""
|
||||
|
||||
ONLINE = "online"
|
||||
IN_PARTY = "in_party"
|
||||
IN_GAME = "in_game"
|
||||
IN_MULTIPLAYER = "in_multiplayer"
|
||||
HAS_GAME_PASS = "has_game_pass"
|
||||
|
||||
|
||||
@@ -81,21 +73,11 @@ SENSOR_DESCRIPTIONS: tuple[XboxBinarySensorEntityDescription, ...] = (
|
||||
entity_picture_fn=profile_pic,
|
||||
attributes_fn=profile_attributes,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_PARTY,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_GAME,
|
||||
translation_key=XboxBinarySensor.IN_GAME,
|
||||
is_on_fn=in_game,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.IN_MULTIPLAYER,
|
||||
is_on_fn=lambda _: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxBinarySensorEntityDescription(
|
||||
key=XboxBinarySensor.HAS_GAME_PASS,
|
||||
translation_key=XboxBinarySensor.HAS_GAME_PASS,
|
||||
@@ -118,9 +100,6 @@ async def async_setup_entry(
|
||||
[
|
||||
XboxBinarySensorEntity(coordinator, entry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if check_deprecated_entity(
|
||||
hass, entry.unique_id, description, BINARY_SENSOR_DOMAIN
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -130,9 +109,6 @@ async def async_setup_entry(
|
||||
XboxBinarySensorEntity(coordinator, subentry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if subentry.unique_id
|
||||
and check_deprecated_entity(
|
||||
hass, subentry.unique_id, description, BINARY_SENSOR_DOMAIN
|
||||
)
|
||||
and subentry.unique_id in coordinator.data.presence
|
||||
and subentry.subentry_type == "friend"
|
||||
],
|
||||
|
||||
@@ -9,8 +9,6 @@ from pythonxbox.api.provider.smartglass.models import ConsoleType, SmartglassCon
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -40,7 +38,6 @@ class XboxBaseEntityDescription(EntityDescription):
|
||||
attributes_fn: Callable[[Person, Title | None], Mapping[str, Any] | None] | None = (
|
||||
None
|
||||
)
|
||||
deprecated: bool | None = None
|
||||
|
||||
|
||||
class XboxBaseEntity(CoordinatorEntity[XboxPresenceCoordinator]):
|
||||
@@ -145,26 +142,6 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxConsoleStatusCoordinator]):
|
||||
return self.coordinator.data.get(self._console.id) is not None
|
||||
|
||||
|
||||
def check_deprecated_entity(
|
||||
hass: HomeAssistant,
|
||||
xuid: str,
|
||||
entity_description: XboxBaseEntityDescription,
|
||||
entity_domain: str,
|
||||
) -> bool:
|
||||
"""Check for deprecated entity and remove it."""
|
||||
if not entity_description.deprecated:
|
||||
return True
|
||||
ent_reg = er.async_get(hass)
|
||||
if entity_id := ent_reg.async_get_entity_id(
|
||||
entity_domain,
|
||||
DOMAIN,
|
||||
f"{xuid}_{entity_description.key}",
|
||||
):
|
||||
ent_reg.async_remove(entity_id)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def to_https(image_url: str) -> str:
|
||||
"""Convert image URLs to secure URLs."""
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from pythonxbox.api.provider.smartglass.models import SmartglassConsole, Storage
|
||||
from pythonxbox.api.provider.titlehub.models import Title
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -27,13 +26,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import XboxConfigEntry, XboxConsolesCoordinator
|
||||
from .entity import (
|
||||
MAP_MODEL,
|
||||
XboxBaseEntity,
|
||||
XboxBaseEntityDescription,
|
||||
check_deprecated_entity,
|
||||
to_https,
|
||||
)
|
||||
from .entity import MAP_MODEL, XboxBaseEntity, XboxBaseEntityDescription, to_https
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -58,8 +51,6 @@ class XboxSensor(StrEnum):
|
||||
|
||||
STATUS = "status"
|
||||
GAMER_SCORE = "gamer_score"
|
||||
ACCOUNT_TIER = "account_tier"
|
||||
GOLD_TENURE = "gold_tenure"
|
||||
LAST_ONLINE = "last_online"
|
||||
FOLLOWING = "following"
|
||||
FOLLOWER = "follower"
|
||||
@@ -200,16 +191,6 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
|
||||
value_fn=lambda x, _: x.gamer_score,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.ACCOUNT_TIER,
|
||||
value_fn=lambda _, __: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.GOLD_TENURE,
|
||||
value_fn=lambda _, __: None,
|
||||
deprecated=True,
|
||||
),
|
||||
XboxSensorEntityDescription(
|
||||
key=XboxSensor.LAST_ONLINE,
|
||||
translation_key=XboxSensor.LAST_ONLINE,
|
||||
@@ -304,9 +285,6 @@ async def async_setup_entry(
|
||||
[
|
||||
XboxSensorEntity(presence, config_entry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if check_deprecated_entity(
|
||||
hass, config_entry.unique_id, description, SENSOR_DOMAIN
|
||||
)
|
||||
]
|
||||
)
|
||||
for subentry_id, subentry in config_entry.subentries.items():
|
||||
@@ -315,9 +293,6 @@ async def async_setup_entry(
|
||||
XboxSensorEntity(presence, subentry.unique_id, description)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
if subentry.unique_id
|
||||
and check_deprecated_entity(
|
||||
hass, subentry.unique_id, description, SENSOR_DOMAIN
|
||||
)
|
||||
and subentry.unique_id in presence.data.presence
|
||||
and subentry.subentry_type == "friend"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user