Compare commits

..

76 Commits

Author SHA1 Message Date
Franck Nijhof 26b0079945 Bump pyenphase to 2.4.9 (#173785) 2026-06-14 14:13:54 -05:00
Raphael Hehl 7454f40dd8 Bump uiprotect to 13.1.2 (#173728) 2026-06-14 19:12:07 +02:00
Franck Nijhof 26b7d1e32c Slugify OwnTracks beacon name in entity ID (#173629) 2026-06-14 19:12:05 +02:00
Franck Nijhof f7342ea9b0 Add missing translation_domain to nasweb exception raises (#173732) 2026-06-14 19:11:42 +02:00
Franck Nijhof 825d99ddaf Bump uv to 0.11.21 (#173768) 2026-06-14 19:09:43 +02:00
Michael 401fae6bdd Bump py-synologydsm-api to 2.10.0 (#173774) 2026-06-14 19:08:49 +02:00
Franck Nijhof 5433beeec1 Skip Miele fan set_percentage when already at the target step (#173725) 2026-06-14 19:06:54 +02:00
G Johansson af60e248d3 Remove listener from holiday calendar when entity is disabled (#173759) 2026-06-14 19:05:33 +02:00
Franck Nijhof 8c452c280f Add missing flow form field translation in hue (#173747) 2026-06-14 19:02:44 +02:00
Franck Nijhof 3aec970321 Add missing flow form field translations in islamic_prayer_times (#173749) 2026-06-14 19:02:03 +02:00
Franck Nijhof 687c91d5f4 Add missing flow form field translation in lacrosse_view (#173750) 2026-06-14 19:01:36 +02:00
Franck Nijhof 377fdceb6c Add missing flow form field translation in melnor (#173752) 2026-06-14 19:00:56 +02:00
Franck Nijhof 11a4533ccc Fix flow form field translation key in meteoclimatic (#173754) 2026-06-14 19:00:12 +02:00
Franck Nijhof 52b2738b2a Add missing flow form field translation in motionblinds_ble (#173758) 2026-06-14 18:59:23 +02:00
Franck Nijhof 3fda722dbb Add missing flow form field translation in blink (#173756) 2026-06-14 18:58:52 +02:00
Franck Nijhof e01215da0e Fix exception translation placeholder mismatch in Swiss Public Transport (#173735) 2026-06-14 18:58:05 +02:00
starkillerOG 6c116cf3e4 Bump reolink_aio to 0.21.1 (#173772) 2026-06-14 18:56:55 +02:00
Franck Nijhof 8017e802dd Bump aiopulse to 0.4.7 (#173763) 2026-06-14 18:56:12 +02:00
Franck Nijhof 501d956b1b Bump pylint to 4.0.6 (#173769) 2026-06-14 18:52:24 +02:00
Franck Nijhof 8aca342a78 Bump snapcast to 2.3.8 (#173765) 2026-06-14 18:51:48 +02:00
Franck Nijhof bd68e9fbe3 Bump syrupy to 5.3.2 (#173767) 2026-06-14 18:51:12 +02:00
Franck Nijhof b75c839868 Bump python-linkplay to 0.2.14 (#173770) 2026-06-14 18:49:59 +02:00
Franck Nijhof 742bfb00ff Bump anova-wifi to 0.17.1 (#173764) 2026-06-14 18:49:24 +02:00
Franck Nijhof 987c19d991 Replace duplicate SERVICE_RELOAD constant with homeassistant.const import in conversation (#173741) 2026-06-14 18:48:55 +02:00
Franck Nijhof b4319c4d0c Bump aioacaia to 0.1.18 (#173762) 2026-06-14 18:35:35 +02:00
Franck Nijhof 0fdb3ebed7 Bump aioamazondevices to 14.0.4 (#173761) 2026-06-14 18:16:06 +02:00
G Johansson efa3334616 Bump lxml to 6.1.1 (#173748) 2026-06-14 18:00:43 +02:00
Franck Nijhof 9ec0f2fe4f Add missing flow form field translation in gogogate2 (#173753) 2026-06-14 17:30:29 +02:00
Franck Nijhof 9bc5e2b06b Fix flow form field translation key in lookin (#173751) 2026-06-14 17:30:21 +02:00
Franck Nijhof 46a38cc481 Add missing flow form field translation in flux_led (#173746) 2026-06-14 17:30:13 +02:00
Franck Nijhof a63f2f1d20 Replace duplicate constants with homeassistant.const imports in Teslemetry (#173744) 2026-06-14 17:26:39 +02:00
Franck Nijhof 744bb6a068 Add missing flow form field translation in tuya (#173745) 2026-06-14 17:26:16 +02:00
Franck Nijhof d449e3e97b Replace duplicate constants with homeassistant.const imports in select (#173743) 2026-06-14 17:25:53 +02:00
Franck Nijhof 0df379704f Replace duplicate CONF_OPTIONS constant with homeassistant.const import in input_select (#173742) 2026-06-14 17:25:24 +02:00
Franck Nijhof 4ab7ce04a8 Add missing exception translation key in Israel Rail (#173738) 2026-06-14 17:24:54 +02:00
Franck Nijhof 210b08b637 Fix exception translation placeholder mismatch in Homevolt (#173737) 2026-06-14 17:24:31 +02:00
Franck Nijhof f0b448dc6e Fix exception translation placeholder mismatch in Snoo (#173736) 2026-06-14 17:24:04 +02:00
Franck Nijhof b5a314bf60 Add missing exception translation keys in VeSync (#173739) 2026-06-14 17:23:38 +02:00
Franck Nijhof 741c342749 Replace duplicate CONF_EVENT constant with homeassistant.const import in calendar (#173740) 2026-06-14 17:23:11 +02:00
Franck Nijhof f4d4df9c35 Fix options flow form field translation key in plaato (#173755) 2026-06-14 17:22:07 +02:00
Franck Nijhof bcbdf7b2bb Add missing flow form field translation in snooz (#173760) 2026-06-14 17:21:15 +02:00
iluebbe b3309ef169 Add Powerline hint to username field description (#167473)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:21 +02:00
epenet caaf5f9715 Adjust pylint checker to prevent invalid use of Platform enum (#173374)
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-14 16:17:00 +02:00
BrettLynch123 7ce7de3650 Fix tessie setup_error on transient aiohttp.ClientError during startup (#173659) 2026-06-14 15:49:22 +02:00
fdebrus 2c14c6be75 Optimistic UI updates for Vistapool write entities (#173373)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-14 15:41:57 +02:00
Christian Lackas e020f338ab Add window state sensor for HomematicIP rotary handle (HmIP-SRH) (#173423) 2026-06-14 15:36:15 +02:00
jasonjhofmann c85c2c4cd3 Add network MAC connection to JVC Projector device (#173683)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:56:29 +02:00
Raman Gupta c4e618e990 Stop validating # of slots in zwave_js.set_credential action (#173644) 2026-06-14 14:18:32 +02:00
Åke Strandberg 5efde60d21 Remove unnecessary #pylint disable..." (#173726) 2026-06-14 14:17:16 +02:00
jasonjhofmann d9dc10ed81 Add network MAC connection to myStrom bulb devices (#173707)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:16:50 +02:00
Onero-testdev cb6ae03d21 Register SwitchBot Standing Fan device (#173577)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:41:17 +02:00
Allen Porter 915b78473c Improve Rainbird config flow test coverage (#173703) 2026-06-14 13:35:54 +02:00
Vincent Wolsink 559006ba19 Adjust humidity attributes to (mandatory) new controller firmware in Huum (#173702) 2026-06-14 13:22:35 +02:00
Thomas D bad2eed9fe Bump qbusmqttapi to v1.5.1 for the Qbus integration (#173714) 2026-06-14 13:14:58 +02:00
Sid 9f1a079688 Bump eheimdigital to 1.7.0 (#173716) 2026-06-14 13:00:52 +02:00
Bipin Kumar 965a96b957 Add Dry and Fan-Only modes to Panasonic CS-CU-EZ18CKYXFM AC in Matter (#173709) 2026-06-14 12:40:37 +02:00
Manu d5791ae8b4 Remove cleanup code for removed entities from Xbox integration (#173688) 2026-06-14 10:51:33 +02:00
J. Nick Koston 7b561934ea Bump aiodiscover to 3.3.2 (#173705) 2026-06-13 22:44:47 -05:00
Franck Nijhof cf60690fb7 Skip building ZHA entity log messages when the level is disabled (#173695) 2026-06-13 22:15:47 +02:00
jasonjhofmann 34d175e452 Add Bluetooth connection to Melnor devices (#173669)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:27:19 +02:00
jasonjhofmann 88f1cb55d4 Add Bluetooth connection to Snooz devices (#173668)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:26:55 +02:00
jasonjhofmann 2972d9eaa5 Add Bluetooth connection to Eurotronic Comet Blue devices (#173670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:24:02 +02:00
Paul Bottein a9de180937 Bump yoto-api to 4.2.1 (#173699) 2026-06-13 20:23:46 +02:00
Paul Bottein 7a898c0eca Add time platform to Yoto (#173617) 2026-06-13 20:23:09 +02:00
A. Gideonse d3d883358c Add optimistic updates for Indevolt (#173091) 2026-06-13 20:20:37 +02:00
karwosts 483f7072dd Add missing template device class translations (#173121) 2026-06-13 18:18:49 +02:00
James Myatt 2db3a5024b Clean up local todo doc strings, locking, and test style (#173461) 2026-06-13 08:43:33 -07:00
Franck Nijhof 0b870e104f Avoid slicing MQTT payload for debug log on every received message (#173693) 2026-06-13 16:24:16 +02:00
Åke Strandberg c5acc04860 Add missing Miele dishwasher codes (#173662) 2026-06-13 14:53:54 +02:00
epenet a1486af33a Add ext_temp as datapoint for Tuya wsdcg category (#173366) 2026-06-13 14:45:00 +02:00
jasonjhofmann 527c0b1fb8 Add network MAC connection to Ring devices (#173671)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:13:05 -04:00
Franck Nijhof d284dff5ce Precompile entity service schemas to avoid per-call recompilation (#173685) 2026-06-13 08:11:11 -04:00
Martin Hoefling 3fbdb88b3c Migrate to knx telegram store (#169700)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthias Alphart <farmio@alphart.net>
2026-06-13 07:43:48 -04:00
Franck Nijhof 9957393f91 Fix workday entity triggering updates while disabled (#173626) 2026-06-13 13:39:02 +02:00
Raphael Hehl 95e6c39e40 Bump uiprotect to 13.1.1 (#173584) 2026-06-13 09:52:11 +02:00
renovate[bot] 54b6c5c542 Update rf-protocols to 4.2.0 (#173650) 2026-06-13 09:50:22 +02:00
171 changed files with 3556 additions and 945 deletions
+1 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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%]"
+1 -1
View File
@@ -5,7 +5,7 @@
"data_description_password": "Password for the FRITZ!Box.",
"data_description_port": "Leave empty to use the default port.",
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
"data_description_username": "Username for the FRITZ!Box.",
"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}"
}
}
}
+2 -1
View File
@@ -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."
+5 -3
View File
@@ -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."""
+3 -1
View File
@@ -136,7 +136,9 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, int_value
)
else:
raise HomeAssistantError(
+3 -1
View File
@@ -106,7 +106,9 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, value
)
else:
raise HomeAssistantError(
+1 -1
View File
@@ -86,7 +86,7 @@ SENSORS: Final = (
),
# Real-time control state
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_COMMAND,
key=IndevoltConfig.READ_REALTIME_STATE,
translation_key="realtime_command",
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
device_class=SensorDeviceClass.ENUM,
+8 -1
View File
@@ -126,7 +126,14 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
)
if success:
await self.coordinator.async_request_refresh()
read_value = (
self.entity_description.read_on_value
if value
else self.entity_description.read_off_value
)
self.coordinator.async_optimistic_update(
self.entity_description.read_key, read_value
)
else:
raise HomeAssistantError(
@@ -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,
+56 -2
View File
@@ -1,6 +1,7 @@
"""The KNX integration."""
import contextlib
import logging
from pathlib import Path
from typing import Final
@@ -17,11 +18,20 @@ from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_KNX_DEFAULT_RATE_LIMIT,
CONF_KNX_DEFAULT_STATE_UPDATER,
CONF_KNX_EXPOSE,
CONF_KNX_KNXKEY_FILENAME,
CONF_KNX_RATE_LIMIT,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
DATA_HASS_CONFIG,
DOMAIN,
KNX_MODULE_KEY,
KNX_TELEGRAM_DB_PATH_SQLITE,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
SUPPORTED_PLATFORMS_UI,
SUPPORTED_PLATFORMS_YAML,
)
@@ -51,11 +61,12 @@ from .schema import (
)
from .services import async_setup_services
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY
from .websocket import register_panel
_KNX_YAML_CONFIG: Final = "knx_yaml_config"
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
@@ -147,6 +158,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
await register_panel(hass)
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", entry.version)
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
new_data = {**entry.data}
new_options = {**entry.options}
new_data.pop("telegram_log_size", None)
for key in (
CONF_KNX_STATE_UPDATER,
CONF_KNX_RATE_LIMIT,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
):
if key in new_data:
new_options[key] = new_data.pop(key)
new_options.setdefault(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS, KNX_TELEGRAM_DB_RETENTION_DEFAULT
)
new_options.setdefault(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS, KNX_TELEGRAM_LOAD_HOURS_DEFAULT
)
new_options.setdefault(CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER)
new_options.setdefault(CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT)
hass.config_entries.async_update_entry(
entry, data=new_data, options=new_options, version=2
)
_LOGGER.info("Migration to version 2 successful")
return True
@@ -203,7 +252,12 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
with contextlib.suppress(FileNotFoundError):
(storage_dir / PROJECT_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / TELEGRAMS_STORAGE_KEY).unlink()
(storage_dir / KNX_TELEGRAM_DB_PATH_SQLITE).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / f"{KNX_TELEGRAM_DB_PATH_SQLITE}-wal").unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / f"{KNX_TELEGRAM_DB_PATH_SQLITE}-shm").unlink()
with contextlib.suppress(FileNotFoundError, OSError):
(storage_dir / DOMAIN).rmdir()
+71 -37
View File
@@ -16,6 +16,7 @@ from xknx.io.self_description import request_description
from xknx.io.util import validate_ip as xknx_validate_ip
from xknx.secure.keyring import Keyring, XMLInterface
from homeassistant import data_entry_flow
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
@@ -48,7 +49,8 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
@@ -56,9 +58,10 @@ from .const import (
DEFAULT_ROUTING_IA,
DOMAIN,
KNX_MODULE_KEY,
TELEGRAM_LOG_DEFAULT,
TELEGRAM_LOG_MAX,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
KNXConfigEntryData,
KNXConfigEntryOptions,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .validation import ia_validator, ip_v4_validator
@@ -71,14 +74,20 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData(
local_ip=None,
multicast_group=DEFAULT_MCAST_GRP,
multicast_port=DEFAULT_MCAST_PORT,
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
route_back=False,
)
DEFAULT_ENTRY_OPTIONS = KNXConfigEntryOptions(
rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT,
state_updater=CONF_KNX_DEFAULT_STATE_UPDATER,
telegram_log_size=TELEGRAM_LOG_DEFAULT,
telegram_db_retention_days=KNX_TELEGRAM_DB_RETENTION_DEFAULT,
telegram_db_load_hours=KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
)
CONF_KEYRING_FILE: Final = "knxkeys_file"
CONF_KNX_TELEGRAM_STORE_SECTION: Final = "telegram_store_section"
CONF_KNX_TUNNELING_TYPE: Final = "tunneling_type"
CONF_KNX_TUNNELING_TYPE_LABELS: Final = {
CONF_KNX_TUNNELING: "UDP (Tunneling v1)",
@@ -103,7 +112,7 @@ _PORT_SELECTOR = vol.All(
class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a KNX config flow."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize KNX config flow."""
@@ -184,6 +193,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=title,
data=DEFAULT_ENTRY_DATA | self.new_entry_data,
options=DEFAULT_ENTRY_OPTIONS,
)
async def async_step_user(
@@ -916,17 +926,16 @@ class KNXOptionsFlow(OptionsFlowWithReload):
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize KNX options flow."""
self.initial_data = dict(config_entry.data)
self.initial_options = dict(config_entry.options)
self.new_entry_options: KNXConfigEntryOptions = {}
@callback
def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult:
def finish_flow(self) -> ConfigFlowResult:
"""Update the ConfigEntry and finish the flow."""
new_data = self.initial_data | new_entry_data
self.hass.config_entries.async_update_entry(
self.config_entry,
data=new_data,
return self.async_create_entry(
title="",
data=self.initial_options | self.new_entry_options,
)
return self.async_create_entry(title="", data={})
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -939,24 +948,29 @@ class KNXOptionsFlow(OptionsFlowWithReload):
) -> ConfigFlowResult:
"""Manage KNX communication settings."""
if user_input is not None:
return self.finish_flow(
KNXConfigEntryData(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE],
)
telegram_store_section = user_input[CONF_KNX_TELEGRAM_STORE_SECTION]
self.new_entry_options |= KNXConfigEntryOptions(
state_updater=user_input[CONF_KNX_STATE_UPDATER],
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
telegram_db_load_hours=telegram_store_section[
CONF_KNX_TELEGRAM_DB_LOAD_HOURS
],
telegram_db_retention_days=telegram_store_section[
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS
],
)
return self.finish_flow()
data_schema = {
vol.Required(
CONF_KNX_STATE_UPDATER,
default=self.initial_data.get(
default=self.initial_options.get(
CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER
),
): selector.BooleanSelector(),
vol.Required(
CONF_KNX_RATE_LIMIT,
default=self.initial_data.get(
default=self.initial_options.get(
CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT
),
): vol.All(
@@ -969,27 +983,47 @@ class KNXOptionsFlow(OptionsFlowWithReload):
),
vol.Coerce(int),
),
vol.Required(
CONF_KNX_TELEGRAM_LOG_SIZE,
default=self.initial_data.get(
CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT
vol.Required(CONF_KNX_TELEGRAM_STORE_SECTION): data_entry_flow.section(
vol.Schema(
{
vol.Required(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
default=self.initial_options.get(
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
KNX_TELEGRAM_LOAD_HOURS_DEFAULT,
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="h",
),
),
vol.Coerce(int),
),
vol.Required(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
default=self.initial_options.get(
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
KNX_TELEGRAM_DB_RETENTION_DEFAULT,
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
mode=selector.NumberSelectorMode.BOX,
unit_of_measurement="days",
),
),
vol.Coerce(int),
),
}
),
): vol.All(
selector.NumberSelector(
selector.NumberSelectorConfig(
min=0,
max=TELEGRAM_LOG_MAX,
mode=selector.NumberSelectorMode.BOX,
),
),
vol.Coerce(int),
),
}
return self.async_show_form(
step_id="communication_settings",
data_schema=vol.Schema(data_schema),
last_step=True,
description_placeholders={
"telegram_log_size_max": f"{TELEGRAM_LOG_MAX}",
},
)
+23 -6
View File
@@ -10,9 +10,11 @@ from xknx.telegram import Telegram
from homeassistant.components.climate import FAN_AUTO, FAN_OFF, HVACAction, HVACMode
from homeassistant.const import Platform
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.signal_type import SignalType
if TYPE_CHECKING:
from .knx_module import KNXModule
from .telegrams import TelegramDict
DOMAIN: Final = "knx"
KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN)
@@ -50,9 +52,18 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0
DEFAULT_ROUTING_IA: Final = "0.0.240"
CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size"
TELEGRAM_LOG_DEFAULT: Final = 1000
TELEGRAM_LOG_MAX: Final = 25000 # ~10 MB or ~25 hours of reasonable bus load
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS: Final = "telegram_db_retention_days"
CONF_KNX_TELEGRAM_DB_LOAD_HOURS: Final = "telegram_db_load_hours"
KNX_TELEGRAM_DB_RETENTION_DEFAULT: Final = 10 # days
KNX_TELEGRAM_LOAD_HOURS_DEFAULT: Final = 24 # 1 day
KNX_TELEGRAM_DB_PATH_SQLITE: Final = "knx/telegrams.db" # relative to STORAGE_DIR
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
)
##
# Secure constants
@@ -94,10 +105,11 @@ SERVICE_KNX_EXPOSURE_REGISTER: Final = "exposure_register"
SERVICE_KNX_READ: Final = "read"
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY: Final = "data_secure_group_key_issue"
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR: Final = "telegram_backend_error"
class KNXConfigEntryData(TypedDict, total=False):
"""Config entry for the KNX integration."""
"""Config entry data for the KNX integration."""
connection_type: str
individual_address: str
@@ -116,11 +128,16 @@ class KNXConfigEntryData(TypedDict, total=False):
knxkeys_password: str # not required
backbone_key: str | None # not required
sync_latency_tolerance: int | None # not required
# OptionsFlow only
class KNXConfigEntryOptions(TypedDict, total=False):
"""Config entry options for the KNX integration."""
state_updater: bool # default state updater: True -> expire 60; False -> init
rate_limit: int
# Integration only (not forwarded to xknx)
telegram_log_size: int # not required
telegram_db_retention_days: int
telegram_db_load_hours: int
class ColorTempModes(Enum):
@@ -39,6 +39,9 @@ async def async_get_config_entry_diagnostics(
}
diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT)
diag["config_entry_options"] = async_redact_data(
dict(config_entry.options), TO_REDACT
)
if proj_info := knx_module.project.info:
diag["project_info"] = async_redact_data(proj_info, "name")
+6 -6
View File
@@ -1,6 +1,7 @@
"""Base module for the KNX integration."""
import logging
from typing import cast
from xknx import XKNX
from xknx.core import XknxConnectionState
@@ -43,13 +44,12 @@ from .const import (
CONF_KNX_SECURE_USER_ID,
CONF_KNX_SECURE_USER_PASSWORD,
CONF_KNX_STATE_UPDATER,
CONF_KNX_TELEGRAM_LOG_SIZE,
CONF_KNX_TUNNEL_ENDPOINT_IA,
CONF_KNX_TUNNELING,
CONF_KNX_TUNNELING_TCP,
CONF_KNX_TUNNELING_TCP_SECURE,
KNX_ADDRESS,
TELEGRAM_LOG_DEFAULT,
KNXConfigEntryOptions,
)
from .device import KNXInterfaceDevice
from .entity import KnxEntityIdentifier
@@ -85,7 +85,7 @@ class KNXModule:
default_state_updater = (
TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60)
if self.entry.data[CONF_KNX_STATE_UPDATER]
if self.entry.options[CONF_KNX_STATE_UPDATER]
else TrackerOptions(
tracker_type=StateTrackerType.INIT, update_interval_min=60
)
@@ -93,7 +93,7 @@ class KNXModule:
self.xknx = XKNX(
address_format=self.project.get_address_format(),
connection_config=self.connection_config(),
rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT],
rate_limit=self.entry.options[CONF_KNX_RATE_LIMIT],
state_updater=default_state_updater,
)
self.xknx.connection_manager.register_connection_state_changed_cb(
@@ -103,7 +103,7 @@ class KNXModule:
hass=hass,
xknx=self.xknx,
project=self.project,
log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT),
config=cast(KNXConfigEntryOptions, entry.options),
)
self.interface_device = KNXInterfaceDevice(
hass=hass, entry=entry, xknx=self.xknx
@@ -131,7 +131,7 @@ class KNXModule:
async def stop(self, event: Event | None = None) -> None:
"""Stop XKNX object. Disconnect from tunneling or Routing device."""
await self.xknx.stop()
await self.telegrams.save_history()
await self.telegrams.stop()
def connection_config(self) -> ConnectionConfig:
"""Return the connection_config."""
+2 -1
View File
@@ -13,7 +13,8 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.9.0",
"knx-frontend==2026.6.1.213802"
"knx-frontend==2026.6.1.213802",
"knx-telegram-store[sqlite]==0.3.2"
],
"single_config_entry": true
}
+26 -1
View File
@@ -15,15 +15,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
if TYPE_CHECKING:
from .knx_module import KNXModule
from .telegrams import TelegramDict
from .const import (
CONF_KNX_KNXKEY_PASSWORD,
DOMAIN,
REPAIR_ISSUE_DATA_SECURE_GROUP_KEY,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
KNXConfigEntryData,
)
from .storage.keyring import DEFAULT_KNX_KEYRING_FILENAME, save_uploaded_knxkeys_file
from .telegrams import SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, TelegramDict
CONF_KEYRING_FILE: Final = "knxkeys_file"
@@ -160,3 +162,26 @@ class DataSecureGroupIssueRepairFlow(RepairsFlow):
self.hass.config_entries.async_update_entry(config_entry, data=new_data)
self.hass.config_entries.async_schedule_reload(config_entry.entry_id)
return self.async_create_entry(data={})
@callback
def async_create_telegram_storage_issue(hass: HomeAssistant) -> None:
"""Create a repair issue for storage initialization failure."""
ir.async_create_issue(
hass,
DOMAIN,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="telegram_storage_error",
)
@callback
def async_delete_telegram_storage_issue(hass: HomeAssistant) -> None:
"""Delete the repair issue for storage initialization failure."""
ir.async_delete_issue(
hass,
DOMAIN,
REPAIR_ISSUE_TELEGRAM_BACKEND_ERROR,
)
+19 -4
View File
@@ -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"
}
+264 -37
View File
@@ -1,8 +1,17 @@
"""KNX Telegram handler."""
"""KNX Telegrams history and storage."""
from collections import deque
from typing import Final, TypedDict
import asyncio
import contextlib
from datetime import datetime
import logging
import os
from typing import Any, TypedDict
from knx_telegram_store import (
BufferedSqliteStore,
KnxTelegramStoreException,
StoredTelegram,
)
from xknx import XKNX
from xknx.dpt import DPTArray, DPTBase, DPTBinary
from xknx.dpt.dpt import DPTComplexData, DPTEnumData
@@ -10,23 +19,34 @@ from xknx.exceptions import XKNXException
from xknx.telegram import Telegram, TelegramDirection
from xknx.telegram.apci import GroupValueResponse, GroupValueWrite
from homeassistant.core import HomeAssistant
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.storage import Store
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.storage import STORAGE_DIR, Store
from homeassistant.util import dt as dt_util
from homeassistant.util.signal_type import SignalType
from .const import DOMAIN
from .project import KNXProject
STORAGE_VERSION: Final = 1
STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json"
# dispatcher signal for KNX interface device triggers
SIGNAL_KNX_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType("knx_telegram")
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM: SignalType[Telegram, TelegramDict] = SignalType(
"knx_data_secure_issue_telegram"
from .const import (
CONF_KNX_TELEGRAM_DB_RETENTION_DAYS,
KNX_TELEGRAM_DB_PATH_SQLITE,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
KNXConfigEntryOptions,
)
from .project import KNXProject
from .repairs import (
async_create_telegram_storage_issue,
async_delete_telegram_storage_issue,
)
_LOGGER = logging.getLogger(__name__)
# Hour of the day (local time) at which expired telegrams are evicted nightly.
EVICT_EXPIRED_HOUR = 3
# Interval at which buffered telegram writes are flushed to the database.
# Websocket queries flush on demand (``flush_first=True``), so the only telegrams
# at risk from a longer interval are those buffered during an ungraceful shutdown.
FLUSH_INTERVAL_SECONDS = 600
class DecodedTelegramPayload(TypedDict):
@@ -62,14 +82,27 @@ class Telegrams:
hass: HomeAssistant,
xknx: XKNX,
project: KNXProject,
log_size: int,
config: KNXConfigEntryOptions,
) -> None:
"""Initialize Telegrams class."""
self.hass = hass
self.project = project
self._history_store = Store[list[TelegramDict]](
hass, STORAGE_VERSION, STORAGE_KEY
self.config = config
self.retention_days: int = config[CONF_KNX_TELEGRAM_DB_RETENTION_DAYS]
self.store: BufferedSqliteStore | None = None
self._uninitialized_store: BufferedSqliteStore | None = None
self._evict_expired_unsub: CALLBACK_TYPE | None = None
full_path = hass.config.path(STORAGE_DIR, KNX_TELEGRAM_DB_PATH_SQLITE)
os.makedirs(os.path.dirname(full_path), exist_ok=True)
self._uninitialized_store = BufferedSqliteStore(
full_path,
retention_days=self.retention_days,
flush_interval=FLUSH_INTERVAL_SECONDS,
)
self._xknx_telegram_cb_handle = (
xknx.telegram_queue.register_telegram_received_cb(
telegram_received_cb=self._xknx_telegram_cb,
@@ -81,43 +114,132 @@ class Telegrams:
self._xknx_data_secure_group_key_issue_cb,
)
)
self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size)
self.last_ga_telegrams: dict[str, TelegramDict] = {}
async def load_history(self) -> None:
"""Load history from store."""
if (telegrams := await self._history_store.async_load()) is None:
if self._uninitialized_store is None:
return
if self.recent_telegrams.maxlen == 0:
await self._history_store.async_remove()
try:
needs_migration = await self._uninitialized_store.needs_migration()
if needs_migration:
_LOGGER.warning(
"KNX telegram history database schema upgrade/migration is required. "
"This may take some time depending on your database size. Please do not restart Home Assistant"
)
await self._uninitialized_store.initialize()
else:
_LOGGER.debug("Initializing KNX telegram storage")
async with asyncio.timeout(10):
await self._uninitialized_store.initialize()
_LOGGER.info("Successfully initialized KNX telegram storage")
except TimeoutError:
_LOGGER.error("Timeout initializing KNX telegram storage")
await self._abort_store_init()
return
for telegram in telegrams:
# tuples are stored as lists in JSON
if isinstance(telegram["payload"], list):
telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable]
self.recent_telegrams.extend(telegrams)
self.last_ga_telegrams = {
t["destination"]: t for t in telegrams if t["payload"] is not None
}
except KnxTelegramStoreException as err:
_LOGGER.error(
"Database error initializing KNX telegram storage: %s",
err,
)
await self._abort_store_init()
return
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error initializing KNX telegram storage: %s",
err,
)
await self._abort_store_init()
return
async_delete_telegram_storage_issue(self.hass)
self.store = self._uninitialized_store
self.store.start()
self._uninitialized_store = None
async def save_history(self) -> None:
"""Save history to store."""
if self.recent_telegrams:
await self._history_store.async_save(list(self.recent_telegrams))
# Evict telegrams older than the retention period once a night. A
# retention of 0 days means all telegrams are deleted on each run.
self._evict_expired_unsub = async_track_time_change(
self.hass,
self._async_evict_expired,
hour=EVICT_EXPIRED_HOUR,
minute=0,
second=0,
)
# Migrate legacy JSON storage if it exists
await self.migrate_telegrams()
# Hydrate last_ga_telegrams from store
try:
result = await self.store.get_last_unique_telegrams()
except KnxTelegramStoreException as err:
_LOGGER.warning("Database error hydrating last_ga_telegrams: %s", err)
return
except Exception as err: # noqa: BLE001
_LOGGER.warning("Error hydrating last_ga_telegrams: %s", err)
return
for m in result:
if m.payload is not None:
t_dict = self.model_to_dict(m)
self.last_ga_telegrams[t_dict["destination"]] = t_dict
_LOGGER.debug("Hydrated %d unique telegrams from store", len(result))
async def _abort_store_init(self) -> None:
"""Create a repair issue and tear down a store that failed to init."""
async_create_telegram_storage_issue(self.hass)
if self._uninitialized_store is not None:
with contextlib.suppress(Exception):
await self._uninitialized_store.close()
self._uninitialized_store = None
async def _async_evict_expired(self, now: datetime) -> None:
"""Delete telegrams older than the configured retention period."""
if self.store is None:
return
try:
deleted = await self.store.evict_expired()
except KnxTelegramStoreException as err:
_LOGGER.warning("Database error evicting expired KNX telegrams: %s", err)
return
_LOGGER.debug("Evicted %d expired KNX telegrams from storage", deleted)
async def stop(self) -> None:
"""Stop history store."""
if self._evict_expired_unsub is not None:
self._evict_expired_unsub()
self._evict_expired_unsub = None
if self.store is None:
return
try:
await self.store.stop()
except KnxTelegramStoreException as err:
_LOGGER.warning(
"Database error stopping KNX telegram storage backend: %s", err
)
except Exception as err: # noqa: BLE001
_LOGGER.warning("Error stopping KNX telegram storage backend: %s", err)
def _xknx_telegram_cb(self, telegram: Telegram) -> None:
"""Handle incoming and outgoing telegrams from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
if telegram_dict["payload"] is not None:
# exclude GroupValueRead telegrams
self.last_ga_telegrams[telegram_dict["destination"]] = telegram_dict
# Store in history store
if self.store is not None:
self.store.store_sync(self.dict_to_model(telegram_dict))
async_dispatcher_send(self.hass, SIGNAL_KNX_TELEGRAM, telegram, telegram_dict)
def _xknx_data_secure_group_key_issue_cb(self, telegram: Telegram) -> None:
"""Handle telegrams with undecodable data secure payload from xknx."""
telegram_dict = self.telegram_to_dict(telegram)
self.recent_telegrams.append(telegram_dict)
# Store in history store
if self.store is not None:
self.store.store_sync(self.dict_to_model(telegram_dict))
async_dispatcher_send(
self.hass, SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM, telegram, telegram_dict
)
@@ -168,6 +290,111 @@ class Telegrams:
value=value,
)
def dict_to_model(self, t: TelegramDict) -> StoredTelegram:
"""Convert a TelegramDict to a StoredTelegram model."""
value = t["value"]
value_numeric: float | None = None
if isinstance(value, (int, float)):
value_numeric = float(value)
payload: Any = t["payload"]
if isinstance(payload, list):
payload = tuple(payload)
return StoredTelegram(
timestamp=dt_util.parse_datetime(t["timestamp"], raise_on_error=True),
source=t["source"],
destination=t["destination"],
direction=t["direction"],
telegramtype=t["telegramtype"],
payload=payload,
value=value,
value_numeric=value_numeric,
dpt_main=t["dpt_main"],
dpt_sub=t["dpt_sub"],
source_name=t["source_name"],
destination_name=t["destination_name"],
data_secure=t["data_secure"],
)
async def migrate_telegrams(self) -> None:
"""Migrate telegrams from JSON storage to the current store."""
if not isinstance(self.store, BufferedSqliteStore):
return
history_store = Store[Any](
self.hass, version=1, key="knx/telegrams_history.json"
)
json_data = await history_store.async_load()
if json_data is None:
return
_LOGGER.info("Migrating KNX telegram history from JSON to KNX Telegram Store")
if not isinstance(json_data, list):
_LOGGER.warning(
"Unexpected format in KNX telegram history JSON, skipping migration"
)
return
stored_telegrams = [self.dict_to_model(t) for t in json_data]
try:
if stored_telegrams:
await self.store.store_many(stored_telegrams)
_LOGGER.info(
"Successfully migrated %d telegrams", len(stored_telegrams)
)
await history_store.async_remove()
except KnxTelegramStoreException as err:
_LOGGER.error("Database error migrating KNX telegram history: %s", err)
except Exception as err: # noqa: BLE001
_LOGGER.error("Error migrating KNX telegram history: %s", err)
def model_to_dict(self, m: StoredTelegram) -> TelegramDict:
"""Convert a StoredTelegram model to a TelegramDict."""
src_name = m.source_name
if not src_name:
if (device := self.project.devices.get(m.source)) is not None:
src_name = f"{device['manufacturer_name']} {device['name']}"
elif m.direction == TelegramDirection.OUTGOING.value:
src_name = "Home Assistant"
dst_name = m.destination_name
if not dst_name:
if (ga_info := self.project.group_addresses.get(m.destination)) is not None:
dst_name = ga_info.name
dpt_name, unit = self._resolve_dpt(m.dpt_main, m.dpt_sub)
return TelegramDict(
timestamp=m.timestamp.isoformat(),
source=m.source,
destination=m.destination,
direction=m.direction,
telegramtype=m.telegramtype,
payload=m.payload,
value=m.value,
dpt_main=m.dpt_main,
dpt_sub=m.dpt_sub,
dpt_name=dpt_name,
unit=unit,
source_name=src_name,
destination_name=dst_name,
data_secure=m.data_secure,
)
def _resolve_dpt(
self, main: int | None, sub: int | None
) -> tuple[str | None, str | None]:
"""Resolve DPT name and unit from main and sub numbers."""
if main is None:
return None, None
if transcoder := DPTBase.parse_transcoder({"main": main, "sub": sub}):
return transcoder.value_type, transcoder.unit
return None, None
def _serializable_decoded_data(
value: bool | float | str | DPTComplexData | DPTEnumData,
+2 -2
View File
@@ -15,9 +15,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType, VolDictType
from .const import DOMAIN
from .const import DOMAIN, SIGNAL_KNX_TELEGRAM
from .schema import ga_validator
from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict, decode_telegram_payload
from .telegrams import TelegramDict, decode_telegram_payload
from .validation import dpt_base_type_validator
TRIGGER_TELEGRAM: Final = "telegram"
+124 -11
View File
@@ -2,11 +2,13 @@
from collections.abc import Awaitable, Callable
from contextlib import ExitStack
from datetime import timedelta
from functools import wraps
import inspect
from typing import TYPE_CHECKING, Any, Final, overload
import knx_frontend as knx_panel
from knx_telegram_store import KnxTelegramStoreException, TelegramQuery
import voluptuous as vol
from xknx.telegram import Telegram
from xknxproject.exceptions import XknxProjectException
@@ -16,12 +18,20 @@ from homeassistant.components.frontend import async_panel_exists
from homeassistant.components.http import StaticPathConfig
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util import dt as dt_util
from homeassistant.util.ulid import ulid_now
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
from .const import (
CONF_KNX_TELEGRAM_DB_LOAD_HOURS,
DOMAIN,
KNX_MODULE_KEY,
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
SUPPORTED_PLATFORMS_UI,
)
from .dpt import get_supported_dpts
from .storage.config_store import ConfigStoreException
from .storage.const import CONF_DATA
@@ -37,11 +47,7 @@ from .storage.entity_store_validation import (
from .storage.expose_controller import validate_expose_data
from .storage.serialize import get_serialized_schema
from .storage.time_server import validate_time_server_data
from .telegrams import (
SIGNAL_KNX_DATA_SECURE_ISSUE_TELEGRAM,
SIGNAL_KNX_TELEGRAM,
TelegramDict,
)
from .telegrams import TelegramDict
if TYPE_CHECKING:
from .knx_module import KNXModule
@@ -56,6 +62,7 @@ async def register_panel(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, ws_project_file_remove)
websocket_api.async_register_command(hass, ws_group_monitor_info)
websocket_api.async_register_command(hass, ws_group_telegrams)
websocket_api.async_register_command(hass, ws_query_telegrams)
websocket_api.async_register_command(hass, ws_subscribe_telegram)
websocket_api.async_register_command(hass, ws_get_knx_project)
websocket_api.async_register_command(hass, ws_validate_entity)
@@ -192,6 +199,15 @@ def ws_get_base_data(
"version": knx.xknx.version,
"connected": knx.xknx.connection_manager.connected.is_set(),
"current_address": str(knx.xknx.current_address),
"telegram_backend": (
"sqlite" if knx.telegrams.store is not None else "unknown"
),
"telegram_retention": knx.telegrams.store.retention_days
if knx.telegrams.store is not None
else None,
"telegram_max_count": knx.telegrams.store.max_telegrams
if knx.telegrams.store is not None
else None,
}
connection.send_result(
@@ -285,21 +301,44 @@ async def ws_project_file_remove(
vol.Required("type"): "knx/group_monitor_info",
}
)
@websocket_api.async_response
@provide_knx
@callback
def ws_group_monitor_info(
async def ws_group_monitor_info(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle get info command of group monitor."""
recent_telegrams = [*knx.telegrams.recent_telegrams]
load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS]
start_time = dt_util.now() - timedelta(hours=load_hours)
query = TelegramQuery(start_time=start_time, order_descending=True)
if knx.telegrams.store is None:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
"Telegram storage backend not initialized. "
"Check logs/Repairs for initialization errors.",
)
return
try:
result = await knx.telegrams.store.query(query, flush_first=True)
except KnxTelegramStoreException as err:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
f"Database error: {err}",
)
return
connection.send_result(
msg["id"],
{
"project_loaded": knx.project.loaded,
"recent_telegrams": recent_telegrams,
"recent_telegrams": [
knx.telegrams.model_to_dict(t) for t in result.telegrams
],
},
)
@@ -325,6 +364,80 @@ def ws_group_telegrams(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "knx/query_telegrams",
vol.Optional("sources"): [str],
vol.Optional("destinations"): [str],
vol.Optional("telegram_types"): [str],
vol.Optional("directions"): [str],
vol.Optional("dpt_mains"): [vol.Coerce(int)],
vol.Optional("start_time"): cv.datetime,
vol.Optional("end_time"): cv.datetime,
vol.Optional("delta_before_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("delta_after_ms"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("limit"): vol.All(vol.Coerce(int), vol.Range(min=1, max=100_000)),
vol.Optional("offset"): vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional("order_descending"): bool,
}
)
@websocket_api.async_response
@provide_knx
async def ws_query_telegrams(
hass: HomeAssistant,
knx: KNXModule,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle query telegrams command."""
start_time = msg.get("start_time")
if start_time is None:
load_hours = knx.entry.options[CONF_KNX_TELEGRAM_DB_LOAD_HOURS]
start_time = dt_util.now() - timedelta(hours=load_hours)
query = TelegramQuery(
sources=msg.get("sources", []),
destinations=msg.get("destinations", []),
telegram_types=msg.get("telegram_types", []),
directions=msg.get("directions", []),
dpt_mains=msg.get("dpt_mains", []),
start_time=start_time,
end_time=msg.get("end_time"),
delta_before_ms=msg.get("delta_before_ms", 0),
delta_after_ms=msg.get("delta_after_ms", 0),
limit=msg.get("limit", 100_000),
offset=msg.get("offset", 0),
order_descending=msg.get("order_descending", True),
)
if knx.telegrams.store is None:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
"Telegram storage backend not initialized. "
"Check logs/Repairs for initialization errors.",
)
return
try:
result = await knx.telegrams.store.query(query, flush_first=True)
except KnxTelegramStoreException as err:
connection.send_error(
msg["id"],
websocket_api.const.ERR_HOME_ASSISTANT_ERROR,
f"Database error: {err}",
)
return
connection.send_result(
msg["id"],
{
"telegrams": [knx.telegrams.model_to_dict(t) for t in result.telegrams],
"total_count": result.total_count,
"limit_reached": result.limit_reached,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -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."]
}
+3 -3
View File
@@ -169,7 +169,7 @@ class LocalTodoListEntity(TodoListEntity):
await self.async_update_ha_state(force_refresh=True)
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update an item to the To-do list."""
"""Update an item in the To-do list."""
todo = _convert_item(item)
async with self._calendar_lock:
todo_store = self._new_todo_store()
@@ -179,10 +179,10 @@ class LocalTodoListEntity(TodoListEntity):
async def async_delete_todo_items(self, uids: list[str]) -> None:
"""Delete an item from the To-do list."""
store = self._new_todo_store()
async with self._calendar_lock:
todo_store = self._new_todo_store()
for uid in uids:
store.delete(uid)
todo_store.delete(uid)
await self.async_save()
await self.async_update_ha_state(force_refresh=True)
+1 -1
View File
@@ -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),
+2 -1
View File
@@ -6,7 +6,7 @@ from melnor_bluetooth.device import Device, Valve
from homeassistant.components.number import EntityDescription
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -30,6 +30,7 @@ class MelnorBluetoothEntity(CoordinatorEntity[MelnorDataUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._device.mac)},
connections={(CONNECTION_BLUETOOTH, self._device.mac)},
manufacturer="Melnor",
model=self._device.model,
name=self._device.name,
@@ -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"
}
}
}
+4 -3
View File
@@ -508,19 +508,20 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True):
solar_save = 9, 34
gentle = 10, 35, 210
extra_quiet = 11, 36, 207
hygiene = 12, 37
quick_power_wash = 13, 38
hygiene = 12, 37, 206
quick_power_wash = 13, 38, 216
pasta_paela = 14
tall_items = 17, 42
glasses_warm = 19
quick_intense = 21
normal = 23, 30
normal = 23, 30, 217
pre_wash = 24
pot_rests_and_filters = 25
power_wash = 44, 204
comfort_wash = 203
comfort_wash_plus = 209
rinse_salt = 215
rinse_and_hold = 219
class TumbleDryerProgramId(MieleEnum, missing_to_none=True):
+2 -2
View File
@@ -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": {
+11 -8
View File
@@ -1332,14 +1332,17 @@ class MQTT:
msg.payload[0:8192],
)
return
_LOGGER.debug(
"Received%s message on %s (qos=%s) IDs=%s: %s",
" retained" if msg.retain else "",
topic,
msg.qos,
identifiers,
msg.payload[0:8192],
)
if _LOGGER.isEnabledFor(logging.DEBUG):
# Guard the debug log so the payload is not sliced (copied) on
# every received message when debug logging is disabled.
_LOGGER.debug(
"Received%s message on %s (qos=%s) IDs=%s: %s",
" retained" if msg.retain else "",
topic,
msg.qos,
identifiers,
msg.payload[0:8192],
)
msg_cache_by_subscription_topic: dict[str, ReceiveMessage] = {}
for subscription in self._matching_subscriptions(topic, identifiers):
+2 -1
View File
@@ -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,
+12 -10
View File
@@ -50,16 +50,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bo
)
if not await webio_api.refresh_device_info():
_LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST])
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_internal_error",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
webio_serial = webio_api.get_serial_number()
if webio_serial is None:
_LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST])
# pylint: disable-next=home-assistant-exception-translation-key-domain-mismatch
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="config_entry_error_internal_error",
translation_placeholders={"support_email": SUPPORT_EMAIL},
)
@@ -67,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)
+1 -1
View File
@@ -65,7 +65,7 @@
"config_entry_error_no_status_update": {
"message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}"
},
"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)
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"]
}
+6 -1
View File
@@ -19,7 +19,11 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import (
@@ -177,6 +181,7 @@ class RingBaseEntity(
self._attr_extra_state_attributes = {}
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_id)}, # device_id is the mac
connections={(CONNECTION_NETWORK_MAC, format_mac(device.device_id))},
manufacturer="Ring",
model=device.model,
name=device.name,
@@ -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"]
}
+1 -2
View File
@@ -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
View File
@@ -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"]
}
+2 -4
View File
@@ -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
+5 -2
View File
@@ -19,7 +19,7 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -85,7 +85,10 @@ class SnoozFan(FanEntity, RestoreEntity):
"""Initialize a Snooz fan entity."""
self._device = data.device
self._attr_unique_id = data.device.address
self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)})
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device.address)},
connections={(CONNECTION_BLUETOOTH, data.device.address)},
)
@callback
def _async_write_state_changed(self) -> None:
+2 -1
View File
@@ -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
+8 -3
View File
@@ -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"]
+1
View File
@@ -711,6 +711,7 @@ class DPCode(StrEnum):
ELECTRICITY_LEFT = "electricity_left"
EXCRETION_TIME_DAY = "excretion_time_day"
EXCRETION_TIMES_DAY = "excretion_times_day"
EXT_TEMP = "ext_temp"
FACTORY_RESET = "factory_reset"
FAN_BEEP = "fan_beep" # Sound
FAN_COOL = "fan_cool" # Cool wind
+6
View File
@@ -1350,6 +1350,12 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.EXT_TEMP,
translation_key="temperature_external",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP_CURRENT,
translation_key="temperature",
@@ -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.",
+1 -4
View File
@@ -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."""
+1 -25
View File
@@ -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"
],
-23
View File
@@ -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."""
+1 -26
View File
@@ -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"
],
+1 -1
View File
@@ -19,7 +19,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.TIME]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
+31 -1
View File
@@ -1,7 +1,10 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from typing import Any
from yoto_api import YotoError, YotoPlayer
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -44,3 +47,30 @@ class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
class YotoPlayerEntity(YotoEntity):
"""Base class for entities reflecting live player state over MQTT."""
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and bool(self.player.is_online)
class YotoConfigEntity(YotoEntity):
"""Base class for entities that write player settings over REST."""
async def _async_set_config(self, **fields: Any) -> None:
"""Write player config fields and refresh the local copy."""
client = self.coordinator.client
try:
await client.set_player_config(self._player_id, **fields)
await client.update_player_info(self._player_id)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="config_update_failed",
translation_placeholders={"error": str(err)},
) from err
self.coordinator.async_set_updated_data(client.players)
+12
View File
@@ -0,0 +1,12 @@
{
"entity": {
"time": {
"day_mode_start": {
"default": "mdi:weather-sunny"
},
"night_mode_start": {
"default": "mdi:weather-night"
}
}
}
}

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