Compare commits

..

34 Commits

Author SHA1 Message Date
epenet
a1e95c483d Migrate metoffice to runtime_data (#164606) 2026-03-03 14:19:57 +01:00
Andreas Jakl
9cb6e02c5f Add binary sensor platform and tests to NRGkick integration (#164629)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-03 13:55:10 +01:00
epenet
2c75e3289a Improve device_info type hints in mobile_app (#164655) 2026-03-03 13:40:56 +01:00
reneboer
348012a6b8 Bump renault-api to 0.5.6 (#164664) 2026-03-03 12:52:41 +01:00
Michael
e0db00e089 Allow the creation of multi-domain triggers (#164628) 2026-03-03 12:52:27 +01:00
Thomas Pfeiffer
b2280198d9 Add equalizer switch for Cambridge Audio devices (#162956) 2026-03-03 12:51:24 +01:00
Artur Pragacz
9cc4a3e427 Trigger recovery mode on registry major version downgrade (#164340) 2026-03-03 11:46:32 +01:00
Raman Gupta
f94a075641 Decouple Vizio apps coordinator from config entry (#163923)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-03 11:22:41 +01:00
hanwg
f1856e6ef6 Update subentry description for Telegram bot (#164642)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 11:21:01 +01:00
mettolen
ed35bafa6c Bump pysaunum to 0.6.0 (#164530) 2026-03-03 11:18:02 +01:00
Manu
66e16d728b Bump python-xbox to 0.2.0 (#164616) 2026-03-03 11:10:14 +01:00
Matthias Alphart
a806efa7e2 Update knx-frontend to 2026.3.2.183756 (#164623) 2026-03-03 11:08:20 +01:00
Norman Yee
ad4b4bd221 Enhance GV5140 test to assert temperature and humidity sensors (#164644) 2026-03-03 11:05:32 +01:00
David Recordon
c9c9a149b6 Bump pylutron-caseta to 0.27.0 (#164614) 2026-03-03 11:03:12 +01:00
epenet
0f9fdfe2de Fix invalid device registry identifiers in eafm (#164654)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-03 11:02:59 +01:00
Abílio Costa
a76b63912d Add Ubisys virtual integration (#164314) 2026-03-03 10:00:57 +00:00
Joshua Monta
bc03e13d38 Bump uhooapi to 1.2.8 (#164648) 2026-03-03 10:59:32 +01:00
Colin
450aa9757d Bump python-openevse-http to 0.2.5 (#164641) 2026-03-03 10:54:58 +01:00
Tom Matheussen
158389a4f2 Remove deprecated YAML import from Satel Integra (#164469) 2026-03-03 10:24:23 +01:00
Raman Gupta
95e89d5ef1 Redact zwave_js dsk key from diagnostics (#164636)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:01:35 +01:00
dependabot[bot]
e107b8e5cd Bump actions/download-artifact from 7.0.0 to 8.0.0 (#164647)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 08:34:36 +01:00
epenet
f875b43ede Remove unnecessary suppress in importlib helper (#164323) 2026-03-03 01:00:32 +01:00
Jeff Terrace
6242ef78c4 Move ONVIF event parsing into a module outside core (#164550)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-02 12:18:05 -10:00
Abílio Costa
3c342c0768 Add infrared platform to ESPHome (#162346)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 22:00:47 +00:00
Norman Yee
5dba5fc79d Add Govee H5140 CO2 monitor support to govee_ble (#164365)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-02 20:12:48 +00:00
James
713b7cf36d Check Daikin zone temp keys before represent (#164297)
Co-authored-by: barneyonline <barneyonline@users.noreply.github.com>
2026-03-02 19:48:39 +00:00
Bram Kragten
cb016b014b Update frontend to 20260302.0 (#164612) 2026-03-02 18:53:01 +01:00
Michael Hansen
afb4523f63 Add device_id and satellite_id to conversation HTTP/websocket APIs (#164414) 2026-03-02 17:01:51 +01:00
Alex Brown
05ad4986ac Fix Matter clear lock user (#164493) 2026-03-02 16:28:49 +01:00
epenet
42dbd5f98f Migrate moat to runtime_data (#164605) 2026-03-02 16:14:25 +01:00
epenet
f58a514ce7 Migrate monzo to runtime_data (#164603) 2026-03-02 16:14:10 +01:00
Artur Pragacz
8fb384a5e1 Raise on vacuum area mapping not configured (#164595) 2026-03-02 15:36:48 +01:00
Samuel Xiao
c24302b5ce Switchbot Cloud: Fixed Smart Radiator Thermostat off line (#162714)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-03-02 14:44:34 +01:00
Jan-Philipp Benecke
999ad9b642 Bump aiotankerkoenig to 0.5.1 (#164590) 2026-03-02 14:44:29 +01:00
131 changed files with 1927 additions and 2676 deletions

View File

@@ -182,7 +182,7 @@ jobs:
fi
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations
@@ -544,7 +544,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: translations

View File

@@ -978,7 +978,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: pytest_buckets
- name: Compile English translations
@@ -1387,7 +1387,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1558,7 +1558,7 @@ jobs:
with:
persist-credentials: false
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: coverage-*
- name: Upload coverage to Codecov
@@ -1587,7 +1587,7 @@ jobs:
&& needs.info.outputs.skip_coverage != 'true' && !cancelled()
steps:
- name: Download all coverage artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
pattern: test-results-*
- name: Upload test results to Codecov

View File

@@ -124,12 +124,12 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_diff
@@ -175,17 +175,17 @@ jobs:
persist-credentials: false
- name: Download env_file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: env_file
- name: Download requirements_diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_diff
- name: Download requirements_all_wheels
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: requirements_all_wheels

View File

@@ -70,7 +70,7 @@ from .const import (
SIGNAL_BOOTSTRAP_INTEGRATIONS,
)
from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError
from .exceptions import HomeAssistantError, UnsupportedStorageVersionError
from .helpers import (
area_registry,
category_registry,
@@ -433,32 +433,56 @@ def _init_blocking_io_modules_in_executor() -> None:
is_docker_env()
async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
"""Load the registries and modules that will do blocking I/O."""
async def async_load_base_functionality(hass: core.HomeAssistant) -> bool:
"""Load the registries and modules that will do blocking I/O.
Return whether loading succeeded.
"""
if DATA_REGISTRIES_LOADED in hass.data:
return
return True
hass.data[DATA_REGISTRIES_LOADED] = None
entity.async_setup(hass)
frame.async_setup(hass)
template.async_setup(hass)
translation.async_setup(hass)
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)),
create_eager_task(category_registry.async_load(hass)),
create_eager_task(device_registry.async_load(hass)),
create_eager_task(entity_registry.async_load(hass)),
create_eager_task(floor_registry.async_load(hass)),
create_eager_task(issue_registry.async_load(hass)),
create_eager_task(label_registry.async_load(hass)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
recovery = hass.config.recovery_mode
try:
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass, load_empty=recovery)),
create_eager_task(category_registry.async_load(hass, load_empty=recovery)),
create_eager_task(device_registry.async_load(hass, load_empty=recovery)),
create_eager_task(entity_registry.async_load(hass, load_empty=recovery)),
create_eager_task(floor_registry.async_load(hass, load_empty=recovery)),
create_eager_task(issue_registry.async_load(hass, load_empty=recovery)),
create_eager_task(label_registry.async_load(hass, load_empty=recovery)),
hass.async_add_executor_job(_init_blocking_io_modules_in_executor),
create_eager_task(template.async_load_custom_templates(hass)),
create_eager_task(restore_state.async_load(hass, load_empty=recovery)),
create_eager_task(hass.config_entries.async_initialize()),
create_eager_task(async_get_system_info(hass)),
create_eager_task(condition.async_setup(hass)),
create_eager_task(trigger.async_setup(hass)),
)
except UnsupportedStorageVersionError as err:
# If we're already in recovery mode, we don't want to handle the exception
# and activate recovery mode again, as that would lead to an infinite loop.
if recovery:
raise
_LOGGER.error(
"Storage file %s was created by a newer version of Home Assistant"
" (storage version %s > %s); activating recovery mode; on-disk data"
" is preserved; upgrade Home Assistant or restore from a backup",
err.storage_key,
err.found_version,
err.max_supported_version,
)
return False
return True
async def async_from_config_dict(
@@ -475,7 +499,9 @@ async def async_from_config_dict(
# Prime custom component cache early so we know if registry entries are tied
# to a custom integration
await loader.async_get_custom_components(hass)
await async_load_base_functionality(hass)
if not await async_load_base_functionality(hass):
return None
# Set up core.
_LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)

View File

@@ -0,0 +1,5 @@
{
"domain": "ubisys",
"name": "Ubisys",
"iot_standards": ["zigbee"]
}

View File

@@ -44,7 +44,7 @@ def make_entity_state_trigger_required_features(
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_domains = {domain}
_to_states = {to_state}
_required_features = required_features

View File

@@ -29,12 +29,17 @@ class StoredBackupData(TypedDict):
class _BackupStore(Store[StoredBackupData]):
"""Class to help storing backup data."""
# Maximum version we support reading for forward compatibility.
# This allows reading data written by a newer HA version after downgrade.
_MAX_READABLE_VERSION = 2
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize storage class."""
super().__init__(
hass,
STORAGE_VERSION,
STORAGE_KEY,
max_readable_version=self._MAX_READABLE_VERSION,
minor_version=STORAGE_VERSION_MINOR,
)
@@ -86,8 +91,8 @@ class _BackupStore(Store[StoredBackupData]):
# data["config"]["schedule"]["state"] will be removed. The bump to 2 is
# planned to happen after a 6 month quiet period with no minor version
# changes.
# Reject if major version is higher than 2.
if old_major_version > 2:
# Reject if major version is higher than _MAX_READABLE_VERSION.
if old_major_version > self._MAX_READABLE_VERSION:
raise NotImplementedError
return data

View File

@@ -24,7 +24,7 @@ class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domain: str = DOMAIN
_domains = {DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""

View File

@@ -14,7 +14,7 @@ from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -29,6 +29,12 @@
"early_update": {
"default": "mdi:update"
},
"equalizer": {
"default": "mdi:equalizer",
"state": {
"off": "mdi:equalizer-outline"
}
},
"pre_amp": {
"default": "mdi:volume-high",
"state": {

View File

@@ -65,6 +65,9 @@
"early_update": {
"name": "Early update"
},
"equalizer": {
"name": "Equalizer"
},
"pre_amp": {
"name": "Pre-Amp"
},

View File

@@ -33,6 +33,13 @@ def room_correction_enabled(client: StreamMagicClient) -> bool:
return client.audio.tilt_eq.enabled
def equalizer_enabled(client: StreamMagicClient) -> bool:
"""Check if equalizer is enabled."""
if TYPE_CHECKING:
assert client.audio.user_eq is not None
return client.audio.user_eq.enabled
CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
CambridgeAudioSwitchEntityDescription(
key="pre_amp",
@@ -56,6 +63,14 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
value_fn=room_correction_enabled,
set_value_fn=lambda client, value: client.set_room_correction_mode(value),
),
CambridgeAudioSwitchEntityDescription(
key="equalizer",
translation_key="equalizer",
entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.audio.user_eq is not None,
value_fn=equalizer_enabled,
set_value_fn=lambda client, value: client.set_equalizer_mode(value),
),
)

View File

@@ -43,7 +43,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:

View File

@@ -48,6 +48,8 @@ def async_setup(hass: HomeAssistant) -> None:
vol.Optional("conversation_id"): vol.Any(str, None),
vol.Optional("language"): str,
vol.Optional("agent_id"): agent_id_validator,
vol.Optional("device_id"): vol.Any(str, None),
vol.Optional("satellite_id"): vol.Any(str, None),
}
)
@websocket_api.async_response
@@ -64,6 +66,8 @@ async def websocket_process(
context=connection.context(msg),
language=msg.get("language"),
agent_id=msg.get("agent_id"),
device_id=msg.get("device_id"),
satellite_id=msg.get("satellite_id"),
)
connection.send_result(msg["id"], result.as_dict())
@@ -248,6 +252,8 @@ class ConversationProcessView(http.HomeAssistantView):
vol.Optional("conversation_id"): str,
vol.Optional("language"): str,
vol.Optional("agent_id"): agent_id_validator,
vol.Optional("device_id"): vol.Any(str, None),
vol.Optional("satellite_id"): vol.Any(str, None),
}
)
)
@@ -262,6 +268,8 @@ class ConversationProcessView(http.HomeAssistantView):
context=self.context(request),
language=data.get("language"),
agent_id=data.get("agent_id"),
device_id=data.get("device_id"),
satellite_id=data.get("satellite_id"),
)
return self.json(result.as_dict())

View File

@@ -112,11 +112,12 @@ def _zone_is_configured(zone: DaikinZone) -> bool:
def _zone_temperature_lists(device: Appliance) -> tuple[list[str], list[str]]:
"""Return the decoded zone temperature lists."""
try:
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
except AttributeError, KeyError:
values = device.values
if DAIKIN_ZONE_TEMP_HEAT not in values or DAIKIN_ZONE_TEMP_COOL not in values:
return ([], [])
heating = device.represent(DAIKIN_ZONE_TEMP_HEAT)[1]
cooling = device.represent(DAIKIN_ZONE_TEMP_COOL)[1]
return (list(heating or []), list(cooling or []))

View File

@@ -2,14 +2,39 @@
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .coordinator import EafmConfigEntry, EafmCoordinator
PLATFORMS = [Platform.SENSOR]
def _fix_device_registry_identifiers(
hass: HomeAssistant, entry: EafmConfigEntry
) -> None:
"""Fix invalid identifiers in device registry.
Added in 2026.4, can be removed in 2026.10 or later.
"""
device_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
old_identifier = (DOMAIN, "measure-id", entry.data["station"])
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
continue
new_identifiers = device_entry.identifiers.copy()
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
new_identifiers.add((DOMAIN, entry.data["station"]))
device_registry.async_update_device(
device_entry.id, new_identifiers=new_identifiers
)
async def async_setup_entry(hass: HomeAssistant, entry: EafmConfigEntry) -> bool:
"""Set up flood monitoring sensors for this config entry."""
_fix_device_registry_identifiers(hass, entry)
coordinator = EafmCoordinator(hass, entry=entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -94,11 +94,11 @@ class Measurement(CoordinatorEntity, SensorEntity):
return self.coordinator.data["measures"][self.key]["parameterName"]
@property
def device_info(self):
def device_info(self) -> DeviceInfo:
"""Return the device info."""
return DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, "measure-id", self.station_id)},
identifiers={(DOMAIN, self.station_id)},
manufacturer="https://environment.data.gov.uk/",
model=self.parameter_name,
name=f"{self.station_name} {self.parameter_name} {self.qualifier}",

View File

@@ -189,6 +189,7 @@ async def platform_async_setup_entry(
info_type: type[_InfoT],
entity_type: type[_EntityT],
state_type: type[_StateT],
info_filter: Callable[[_InfoT], bool] | None = None,
) -> None:
"""Set up an esphome platform.
@@ -208,10 +209,22 @@ async def platform_async_setup_entry(
entity_type,
state_type,
)
if info_filter is not None:
def on_filtered_update(infos: list[EntityInfo]) -> None:
on_static_info_update(
[info for info in infos if info_filter(cast(_InfoT, info))]
)
info_callback = on_filtered_update
else:
info_callback = on_static_info_update
entry_data.cleanup_callbacks.append(
entry_data.async_register_static_info_callback(
info_type,
on_static_info_update,
info_callback,
)
)

View File

@@ -29,6 +29,7 @@ from aioesphomeapi import (
Event,
EventInfo,
FanInfo,
InfraredInfo,
LightInfo,
LockInfo,
MediaPlayerInfo,
@@ -85,6 +86,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
DateTimeInfo: Platform.DATETIME,
EventInfo: Platform.EVENT,
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,

View File

@@ -0,0 +1,59 @@
"""Infrared platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
# Infrared entities should go available as soon as the device comes online
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending command: %s", timings)
self._client.infrared_rf_transmit_raw_timings(
self._static_info.key,
carrier_frequency=command.modulation,
timings=timings,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=EsphomeInfraredEntity,
state_type=EntityState,
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
)

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260226.0"]
"requirements": ["home-assistant-frontend==20260302.0"]
}

View File

@@ -54,6 +54,10 @@
"connectable": false,
"local_name": "GVH5110*"
},
{
"connectable": false,
"local_name": "GV5140*"
},
{
"connectable": false,
"manufacturer_id": 1,

View File

@@ -21,6 +21,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfTemperature,
@@ -72,6 +73,12 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
}

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.15.0",
"xknxproject==3.8.2",
"knx-frontend==2026.2.25.165736"
"knx-frontend==2026.3.2.183756"
],
"single_config_entry": true
}

View File

@@ -23,7 +23,7 @@ def _convert_uint8_to_percentage(value: Any) -> float:
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain = DOMAIN
_domains = {DOMAIN}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
@@ -34,7 +34,7 @@ class BrightnessCrossedThresholdTrigger(
):
"""Trigger for brightness crossed threshold."""
_domain = DOMAIN
_domains = {DOMAIN}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)

View File

@@ -10,7 +10,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.26.0"],
"requirements": ["pylutron-caseta==0.27.0"],
"zeroconf": [
{
"properties": {

View File

@@ -14,7 +14,6 @@ from chip.clusters.Types import NullValue
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from .const import (
CLEAR_ALL_INDEX,
CRED_TYPE_FACE,
CRED_TYPE_FINGER_VEIN,
CRED_TYPE_FINGERPRINT,
@@ -222,42 +221,6 @@ def _format_user_response(user_data: Any) -> LockUserData | None:
# --- Credential management helpers ---
async def _clear_user_credentials(
matter_client: MatterClient,
node_id: int,
endpoint_id: int,
user_index: int,
) -> None:
"""Clear all credentials for a specific user.
Fetches the user to get credential list, then clears each credential.
"""
get_user_response = await matter_client.send_device_command(
node_id=node_id,
endpoint_id=endpoint_id,
command=clusters.DoorLock.Commands.GetUser(userIndex=user_index),
)
creds = _get_attr(get_user_response, "credentials")
if not creds:
return
for cred in creds:
cred_type = _get_attr(cred, "credentialType")
cred_index = _get_attr(cred, "credentialIndex")
await matter_client.send_device_command(
node_id=node_id,
endpoint_id=endpoint_id,
command=clusters.DoorLock.Commands.ClearCredential(
credential=clusters.DoorLock.Structs.CredentialStruct(
credentialType=cred_type,
credentialIndex=cred_index,
),
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
class LockEndpointNotFoundError(HomeAssistantError):
"""Lock endpoint not found on node."""
@@ -557,33 +520,16 @@ async def clear_lock_user(
node: MatterNode,
user_index: int,
) -> None:
"""Clear a user from the lock, cleaning up credentials first.
"""Clear a user from the lock.
Per the Matter spec, ClearUser also clears all associated credentials
and schedules for the user.
Use index 0xFFFE (CLEAR_ALL_INDEX) to clear all users.
Raises HomeAssistantError on failure.
"""
lock_endpoint = _get_lock_endpoint_or_raise(node)
_ensure_usr_support(lock_endpoint)
if user_index == CLEAR_ALL_INDEX:
# Clear all: clear all credentials first, then all users
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,
command=clusters.DoorLock.Commands.ClearCredential(
credential=None,
),
timed_request_timeout_ms=LOCK_TIMED_REQUEST_TIMEOUT_MS,
)
else:
# Clear credentials for this specific user before deleting them
await _clear_user_credentials(
matter_client,
node.node_id,
lock_endpoint.endpoint_id,
user_index,
)
await matter_client.send_device_command(
node_id=node.node_id,
endpoint_id=lock_endpoint.endpoint_id,

View File

@@ -18,20 +18,17 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from .const import (
DOMAIN,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
METOFFICE_TWICE_DAILY_COORDINATOR,
from .const import DOMAIN
from .coordinator import (
MetOfficeConfigEntry,
MetOfficeRuntimeData,
MetOfficeUpdateCoordinator,
)
from .coordinator import MetOfficeUpdateCoordinator
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MetOfficeConfigEntry) -> bool:
"""Set up a Met Office entry."""
latitude: float = entry.data[CONF_LATITUDE]
@@ -39,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_key: str = entry.data[CONF_API_KEY]
site_name: str = entry.data[CONF_NAME]
coordinates = f"{latitude}_{longitude}"
connection = Manager(api_key=api_key)
metoffice_hourly_coordinator = MetOfficeUpdateCoordinator(
@@ -73,21 +68,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
frequency="twice-daily",
)
metoffice_hass_data = hass.data.setdefault(DOMAIN, {})
metoffice_hass_data[entry.entry_id] = {
METOFFICE_HOURLY_COORDINATOR: metoffice_hourly_coordinator,
METOFFICE_DAILY_COORDINATOR: metoffice_daily_coordinator,
METOFFICE_TWICE_DAILY_COORDINATOR: metoffice_twice_daily_coordinator,
METOFFICE_NAME: site_name,
METOFFICE_COORDINATES: coordinates,
}
# Fetch initial data so we have data when entities subscribe
await asyncio.gather(
metoffice_hourly_coordinator.async_config_entry_first_refresh(),
metoffice_daily_coordinator.async_config_entry_first_refresh(),
)
entry.runtime_data = MetOfficeRuntimeData(
coordinates=f"{latitude}_{longitude}",
hourly_coordinator=metoffice_hourly_coordinator,
daily_coordinator=metoffice_daily_coordinator,
twice_daily_coordinator=metoffice_twice_daily_coordinator,
name=site_name,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -95,12 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def get_device_info(coordinates: str, name: str) -> DeviceInfo:

View File

@@ -38,13 +38,6 @@ ATTRIBUTION = "Data provided by the Met Office"
DEFAULT_SCAN_INTERVAL = timedelta(minutes=15)
METOFFICE_COORDINATES = "metoffice_coordinates"
METOFFICE_HOURLY_COORDINATOR = "metoffice_hourly_coordinator"
METOFFICE_DAILY_COORDINATOR = "metoffice_daily_coordinator"
METOFFICE_TWICE_DAILY_COORDINATOR = "metoffice_twice_daily_coordinator"
METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions"
METOFFICE_NAME = "metoffice_name"
CONDITION_CLASSES: dict[str, list[int]] = {
ATTR_CONDITION_CLEAR_NIGHT: [0],
ATTR_CONDITION_CLOUDY: [7, 8],

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Literal
@@ -22,6 +23,19 @@ from .const import DEFAULT_SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
type MetOfficeConfigEntry = ConfigEntry[MetOfficeRuntimeData]
@dataclass
class MetOfficeRuntimeData:
"""Met Office config entry."""
coordinates: str
hourly_coordinator: MetOfficeUpdateCoordinator
daily_coordinator: MetOfficeUpdateCoordinator
twice_daily_coordinator: MetOfficeUpdateCoordinator
name: str
class MetOfficeUpdateCoordinator(TimestampDataUpdateCoordinator[Forecast]):
"""Coordinator for Met Office forecast data."""

View File

@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
@@ -30,15 +29,12 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_device_info
from .const import (
ATTRIBUTION,
CONDITION_MAP,
DOMAIN,
METOFFICE_COORDINATES,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN
from .coordinator import (
MetOfficeConfigEntry,
MetOfficeRuntimeData,
MetOfficeUpdateCoordinator,
)
from .coordinator import MetOfficeUpdateCoordinator
from .helpers import get_attribute
ATTR_LAST_UPDATE = "last_update"
@@ -172,19 +168,19 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MetOfficeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Met Office weather sensor platform."""
entity_registry = er.async_get(hass)
hass_data = hass.data[DOMAIN][entry.entry_id]
hass_data = entry.runtime_data
# Remove daily entities from legacy config entries
for description in SENSOR_TYPES:
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"{description.key}_{hass_data[METOFFICE_COORDINATES]}_daily",
f"{description.key}_{hass_data.coordinates}_daily",
):
entity_registry.async_remove(entity_id)
@@ -192,20 +188,20 @@ async def async_setup_entry(
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}_daily",
f"visibility_distance_{hass_data.coordinates}_daily",
):
entity_registry.async_remove(entity_id)
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"visibility_distance_{hass_data[METOFFICE_COORDINATES]}",
f"visibility_distance_{hass_data.coordinates}",
):
entity_registry.async_remove(entity_id)
async_add_entities(
[
MetOfficeCurrentSensor(
hass_data[METOFFICE_HOURLY_COORDINATOR],
hass_data.hourly_coordinator,
hass_data,
description,
)
@@ -228,7 +224,7 @@ class MetOfficeCurrentSensor(
def __init__(
self,
coordinator: MetOfficeUpdateCoordinator,
hass_data: dict[str, Any],
hass_data: MetOfficeRuntimeData,
description: MetOfficeSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
@@ -237,9 +233,9 @@ class MetOfficeCurrentSensor(
self.entity_description = description
self._attr_device_info = get_device_info(
coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME]
coordinates=hass_data.coordinates, name=hass_data.name
)
self._attr_unique_id = f"{description.key}_{hass_data[METOFFICE_COORDINATES]}"
self._attr_unique_id = f"{description.key}_{hass_data.coordinates}"
@property
def native_value(self) -> StateType:

View File

@@ -23,7 +23,6 @@ from homeassistant.components.weather import (
Forecast as WeatherForecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfLength,
UnitOfPressure,
@@ -42,40 +41,39 @@ from .const import (
DAY_FORECAST_ATTRIBUTE_MAP,
DOMAIN,
HOURLY_FORECAST_ATTRIBUTE_MAP,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
METOFFICE_TWICE_DAILY_COORDINATOR,
NIGHT_FORECAST_ATTRIBUTE_MAP,
)
from .coordinator import MetOfficeUpdateCoordinator
from .coordinator import (
MetOfficeConfigEntry,
MetOfficeRuntimeData,
MetOfficeUpdateCoordinator,
)
from .helpers import get_attribute
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: MetOfficeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Met Office weather sensor platform."""
entity_registry = er.async_get(hass)
hass_data = hass.data[DOMAIN][entry.entry_id]
hass_data = entry.runtime_data
# Remove daily entity from legacy config entries
if entity_id := entity_registry.async_get_entity_id(
WEATHER_DOMAIN,
DOMAIN,
f"{hass_data[METOFFICE_COORDINATES]}_daily",
f"{hass_data.coordinates}_daily",
):
entity_registry.async_remove(entity_id)
async_add_entities(
[
MetOfficeWeather(
hass_data[METOFFICE_DAILY_COORDINATOR],
hass_data[METOFFICE_HOURLY_COORDINATOR],
hass_data[METOFFICE_TWICE_DAILY_COORDINATOR],
hass_data.daily_coordinator,
hass_data.hourly_coordinator,
hass_data.twice_daily_coordinator,
hass_data,
)
],
@@ -178,7 +176,7 @@ class MetOfficeWeather(
coordinator_daily: MetOfficeUpdateCoordinator,
coordinator_hourly: MetOfficeUpdateCoordinator,
coordinator_twice_daily: MetOfficeUpdateCoordinator,
hass_data: dict[str, Any],
hass_data: MetOfficeRuntimeData,
) -> None:
"""Initialise the platform with a data instance."""
observation_coordinator = coordinator_hourly
@@ -190,9 +188,9 @@ class MetOfficeWeather(
)
self._attr_device_info = get_device_info(
coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME]
coordinates=hass_data.coordinates, name=hass_data.name
)
self._attr_unique_id = hass_data[METOFFICE_COORDINATES]
self._attr_unique_id = hass_data.coordinates
@property
def condition(self) -> str | None:

View File

@@ -14,27 +14,26 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
type MoatConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MoatConfigEntry) -> bool:
"""Set up Moat BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = MoatBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=data.update,
)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=data.update,
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
@@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MoatConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -4,12 +4,10 @@ from __future__ import annotations
from moat_ble import DeviceClass, DeviceKey, SensorUpdate, Units
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
@@ -28,7 +26,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
from . import MoatConfigEntry
SENSOR_DESCRIPTIONS = {
(DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription(
@@ -104,13 +102,11 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: MoatConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Moat BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
coordinator = entry.runtime_data
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(

View File

@@ -13,6 +13,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
@@ -95,7 +96,7 @@ class MobileAppEntity(RestoreEntity):
config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
@property
def device_info(self):
def device_info(self) -> DeviceInfo:
"""Return device registry information for this entity."""
return device_info(self._registration)

View File

@@ -193,7 +193,7 @@ def webhook_response(
)
def device_info(registration: dict) -> DeviceInfo:
def device_info(registration: Mapping[str, Any]) -> DeviceInfo:
"""Return the device info for this registration."""
return DeviceInfo(
identifiers={(DOMAIN, registration[ATTR_DEVICE_ID])},

View File

@@ -1,11 +1,8 @@
"""The Monoprice 6-Zone Amplifier integration."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from pymonoprice import Monoprice, get_monoprice
from pymonoprice import get_monoprice
from serial import SerialException
from homeassistant.config_entries import ConfigEntry
@@ -13,24 +10,14 @@ from homeassistant.const import CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_NOT_FIRST_RUN
from .const import CONF_NOT_FIRST_RUN, DOMAIN, FIRST_RUN, MONOPRICE_OBJECT
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
type MonopriceConfigEntry = ConfigEntry[MonopriceRuntimeData]
@dataclass
class MonopriceRuntimeData:
"""Data stored in the config entry for a Monoprice entry."""
client: Monoprice
first_run: bool
async def async_setup_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Monoprice 6-Zone Amplifier from a config entry."""
port = entry.data[CONF_PORT]
@@ -50,17 +37,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(_update_listener))
entry.runtime_data = MonopriceRuntimeData(
client=monoprice,
first_run=first_run,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
MONOPRICE_OBJECT: monoprice,
FIRST_RUN: first_run,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not unload_ok:
@@ -74,7 +61,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: MonopriceConfigEntry) -
"""
del monoprice
await hass.async_add_executor_job(_cleanup, entry.runtime_data.client)
monoprice = hass.data[DOMAIN][entry.entry_id][MONOPRICE_OBJECT]
hass.data[DOMAIN].pop(entry.entry_id)
await hass.async_add_executor_job(_cleanup, monoprice)
return True

View File

@@ -15,3 +15,6 @@ CONF_NOT_FIRST_RUN = "not_first_run"
SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
FIRST_RUN = "first_run"
MONOPRICE_OBJECT = "monoprice_object"

View File

@@ -11,14 +11,21 @@ from homeassistant.components.media_player import (
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform, service
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MonopriceConfigEntry
from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT
from .const import (
CONF_SOURCES,
DOMAIN,
FIRST_RUN,
MONOPRICE_OBJECT,
SERVICE_RESTORE,
SERVICE_SNAPSHOT,
)
_LOGGER = logging.getLogger(__name__)
@@ -50,13 +57,13 @@ def _get_sources(config_entry):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MonopriceConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Monoprice 6-zone amplifier platform."""
port = config_entry.data[CONF_PORT]
monoprice = config_entry.runtime_data.client
monoprice = hass.data[DOMAIN][config_entry.entry_id][MONOPRICE_OBJECT]
sources = _get_sources(config_entry)
@@ -70,7 +77,8 @@ async def async_setup_entry(
)
# only call update before add if it's the first run so we can try to detect zones
async_add_entities(entities, config_entry.runtime_data.first_run)
first_run = hass.data[DOMAIN][config_entry.entry_id][FIRST_RUN]
async_add_entities(entities, first_run)
platform = entity_platform.async_get_current_platform()

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -14,15 +13,14 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from .api import AuthenticatedMonzoAPI
from .const import DOMAIN
from .coordinator import MonzoCoordinator
from .coordinator import MonzoConfigEntry, MonzoCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool:
"""Migrate entry."""
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
@@ -39,7 +37,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool:
"""Set up Monzo from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
@@ -51,15 +49,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: MonzoConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,5 +1,7 @@
"""The Monzo integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -18,6 +20,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type MonzoConfigEntry = ConfigEntry[MonzoCoordinator]
@dataclass
class MonzoData:
@@ -30,10 +34,13 @@ class MonzoData:
class MonzoCoordinator(DataUpdateCoordinator[MonzoData]):
"""Class to manage fetching Monzo data from the API."""
config_entry: ConfigEntry
config_entry: MonzoConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, api: AuthenticatedMonzoAPI
self,
hass: HomeAssistant,
config_entry: MonzoConfigEntry,
api: AuthenticatedMonzoAPI,
) -> None:
"""Initialize."""
super().__init__(

View File

@@ -11,14 +11,11 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MonzoCoordinator
from .const import DOMAIN
from .coordinator import MonzoData
from .coordinator import MonzoConfigEntry, MonzoCoordinator, MonzoData
from .entity import MonzoBaseEntity
@@ -64,11 +61,11 @@ MODEL_POT = "Pot"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: MonzoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
accounts = [
MonzoSensor(

View File

@@ -11,6 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import NRGkickConfigEntry, NRGkickDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,

View File

@@ -0,0 +1,76 @@
"""Binary sensor platform for NRGkick."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator
from .entity import NRGkickEntity, get_nested_dict_value
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class NRGkickBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class describing NRGkick binary sensor entities."""
is_on_fn: Callable[[NRGkickData], bool | None]
BINARY_SENSORS: tuple[NRGkickBinarySensorEntityDescription, ...] = (
NRGkickBinarySensorEntityDescription(
key="charge_permitted",
translation_key="charge_permitted",
is_on_fn=lambda data: (
bool(value)
if (
value := get_nested_dict_value(
data.values, "general", "charge_permitted"
)
)
is not None
else None
),
),
)
async def async_setup_entry(
_hass: HomeAssistant,
entry: NRGkickConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NRGkick binary sensors based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
NRGkickBinarySensor(coordinator, description) for description in BINARY_SENSORS
)
class NRGkickBinarySensor(NRGkickEntity, BinarySensorEntity):
"""Representation of a NRGkick binary sensor."""
entity_description: NRGkickBinarySensorEntityDescription
def __init__(
self,
coordinator: NRGkickDataUpdateCoordinator,
entity_description: NRGkickBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
@property
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
return self.entity_description.is_on_fn(self.coordinator.data)

View File

@@ -14,6 +14,17 @@ from .const import DOMAIN
from .coordinator import NRGkickDataUpdateCoordinator
def get_nested_dict_value(data: Any, *keys: str) -> Any:
"""Safely get a nested value from dict-like API responses."""
current: Any = data
for key in keys:
try:
current = current.get(key)
except AttributeError:
return None
return current
class NRGkickEntity(CoordinatorEntity[NRGkickDataUpdateCoordinator]):
"""Base class for NRGkick entities with common device info setup."""

View File

@@ -1,5 +1,10 @@
{
"entity": {
"binary_sensor": {
"charge_permitted": {
"default": "mdi:ev-station"
}
},
"number": {
"current_set": {
"default": "mdi:current-ac"

View File

@@ -45,22 +45,11 @@ from .const import (
WARNING_CODE_MAP,
)
from .coordinator import NRGkickConfigEntry, NRGkickData, NRGkickDataUpdateCoordinator
from .entity import NRGkickEntity
from .entity import NRGkickEntity, get_nested_dict_value
PARALLEL_UPDATES = 0
def _get_nested_dict_value(data: Any, *keys: str) -> Any:
"""Safely get a nested value from dict-like API responses."""
current: Any = data
for key in keys:
try:
current = current.get(key)
except AttributeError:
return None
return current
@dataclass(frozen=True, kw_only=True)
class NRGkickSensorEntityDescription(SensorEntityDescription):
"""Class describing NRGkick sensor entities."""
@@ -159,7 +148,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.info, "general", "rated_current"
),
),
@@ -167,7 +156,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
NRGkickSensorEntityDescription(
key="connector_phase_count",
translation_key="connector_phase_count",
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.info, "connector", "phase_count"
),
),
@@ -178,7 +167,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.info, "connector", "max_current"
),
),
@@ -189,7 +178,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
options=_enum_options_from_mapping(CONNECTOR_TYPE_MAP),
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _map_code_to_translation_key(
cast(StateType, _get_nested_dict_value(data.info, "connector", "type")),
cast(StateType, get_nested_dict_value(data.info, "connector", "type")),
CONNECTOR_TYPE_MAP,
),
),
@@ -198,7 +187,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
translation_key="connector_serial",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(data.info, "connector", "serial"),
value_fn=lambda data: get_nested_dict_value(data.info, "connector", "serial"),
),
# INFO - Grid
NRGkickSensorEntityDescription(
@@ -208,7 +197,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(data.info, "grid", "voltage"),
value_fn=lambda data: get_nested_dict_value(data.info, "grid", "voltage"),
),
NRGkickSensorEntityDescription(
key="grid_frequency",
@@ -217,7 +206,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(data.info, "grid", "frequency"),
value_fn=lambda data: get_nested_dict_value(data.info, "grid", "frequency"),
),
# INFO - Network
NRGkickSensorEntityDescription(
@@ -225,7 +214,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
translation_key="network_ssid",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(data.info, "network", "ssid"),
value_fn=lambda data: get_nested_dict_value(data.info, "network", "ssid"),
),
NRGkickSensorEntityDescription(
key="network_rssi",
@@ -234,7 +223,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(data.info, "network", "rssi"),
value_fn=lambda data: get_nested_dict_value(data.info, "network", "rssi"),
),
# INFO - Cellular (optional, only if cellular module is available)
NRGkickSensorEntityDescription(
@@ -246,7 +235,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
requires_sim_module=True,
value_fn=lambda data: _map_code_to_translation_key(
cast(StateType, _get_nested_dict_value(data.info, "cellular", "mode")),
cast(StateType, get_nested_dict_value(data.info, "cellular", "mode")),
CELLULAR_MODE_MAP,
),
),
@@ -259,7 +248,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
requires_sim_module=True,
value_fn=lambda data: _get_nested_dict_value(data.info, "cellular", "rssi"),
value_fn=lambda data: get_nested_dict_value(data.info, "cellular", "rssi"),
),
NRGkickSensorEntityDescription(
key="cellular_operator",
@@ -267,7 +256,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
requires_sim_module=True,
value_fn=lambda data: _get_nested_dict_value(data.info, "cellular", "operator"),
value_fn=lambda data: get_nested_dict_value(data.info, "cellular", "operator"),
),
# VALUES - Energy
NRGkickSensorEntityDescription(
@@ -278,7 +267,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=3,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "energy", "total_charged_energy"
),
),
@@ -290,7 +279,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=3,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "energy", "charged_energy"
),
),
@@ -302,7 +291,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "charging_voltage"
),
),
@@ -313,7 +302,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "charging_current"
),
),
@@ -326,7 +315,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "grid_frequency"
),
),
@@ -339,7 +328,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "peak_power"
),
),
@@ -350,7 +339,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "total_active_power"
),
),
@@ -362,7 +351,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "total_reactive_power"
),
),
@@ -374,7 +363,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "total_apparent_power"
),
),
@@ -386,7 +375,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "total_power_factor"
),
),
@@ -400,7 +389,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "voltage"
),
),
@@ -411,7 +400,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "current"
),
),
@@ -422,7 +411,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "active_power"
),
),
@@ -434,7 +423,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "reactive_power"
),
),
@@ -446,7 +435,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "apparent_power"
),
),
@@ -458,7 +447,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l1", "power_factor"
),
),
@@ -472,7 +461,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "voltage"
),
),
@@ -483,7 +472,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "current"
),
),
@@ -494,7 +483,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "active_power"
),
),
@@ -506,7 +495,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "reactive_power"
),
),
@@ -518,7 +507,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "apparent_power"
),
),
@@ -530,7 +519,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l2", "power_factor"
),
),
@@ -544,7 +533,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "voltage"
),
),
@@ -555,7 +544,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "current"
),
),
@@ -566,7 +555,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=2,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "active_power"
),
),
@@ -578,7 +567,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "reactive_power"
),
),
@@ -590,7 +579,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "apparent_power"
),
),
@@ -602,7 +591,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "l3", "power_factor"
),
),
@@ -616,7 +605,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
suggested_display_precision=2,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "powerflow", "n", "current"
),
),
@@ -626,7 +615,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
translation_key="charging_rate",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "general", "charging_rate"
),
),
@@ -638,12 +627,12 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
_seconds_to_stable_timestamp(
cast(
StateType,
_get_nested_dict_value(
get_nested_dict_value(
data.values, "general", "vehicle_connect_time"
),
)
)
if _get_nested_dict_value(data.values, "general", "status")
if get_nested_dict_value(data.values, "general", "status")
!= ChargingStatus.STANDBY
else None
),
@@ -655,7 +644,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.MINUTES,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "general", "vehicle_charging_time"
),
),
@@ -665,7 +654,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=_enum_options_from_mapping(STATUS_MAP),
value_fn=lambda data: _map_code_to_translation_key(
cast(StateType, _get_nested_dict_value(data.values, "general", "status")),
cast(StateType, get_nested_dict_value(data.values, "general", "status")),
STATUS_MAP,
),
),
@@ -675,7 +664,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=0,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "general", "charge_count"
),
),
@@ -687,7 +676,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _map_code_to_translation_key(
cast(
StateType, _get_nested_dict_value(data.values, "general", "rcd_trigger")
StateType, get_nested_dict_value(data.values, "general", "rcd_trigger")
),
RCD_TRIGGER_MAP,
),
@@ -700,8 +689,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _map_code_to_translation_key(
cast(
StateType,
_get_nested_dict_value(data.values, "general", "warning_code"),
StateType, get_nested_dict_value(data.values, "general", "warning_code")
),
WARNING_CODE_MAP,
),
@@ -714,7 +702,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _map_code_to_translation_key(
cast(
StateType, _get_nested_dict_value(data.values, "general", "error_code")
StateType, get_nested_dict_value(data.values, "general", "error_code")
),
ERROR_CODE_MAP,
),
@@ -727,7 +715,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "housing"
),
),
@@ -738,7 +726,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "connector_l1"
),
),
@@ -749,7 +737,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "connector_l2"
),
),
@@ -760,7 +748,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "connector_l3"
),
),
@@ -771,7 +759,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "domestic_plug_1"
),
),
@@ -782,7 +770,7 @@ SENSORS: tuple[NRGkickSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: _get_nested_dict_value(
value_fn=lambda data: get_nested_dict_value(
data.values, "temperatures", "domestic_plug_2"
),
),

View File

@@ -78,6 +78,11 @@
}
},
"entity": {
"binary_sensor": {
"charge_permitted": {
"name": "Charge permitted"
}
},
"number": {
"current_set": {
"name": "Charging current"

View File

@@ -16,23 +16,30 @@ from onvif.client import (
)
from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
import onvif_parsers
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER
from .models import Event, PullPointManagerState, WebHookManagerState
from .parsers import PARSERS
# Topics in this list are ignored because we do not want to create
# entities for them.
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
ENTITY_CATEGORY_MAPPING: dict[str, EntityCategory] = {
"diagnostic": EntityCategory.DIAGNOSTIC,
"config": EntityCategory.CONFIG,
}
SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError)
CREATE_ERRORS = (
ONVIFError,
@@ -81,6 +88,18 @@ PULLPOINT_MESSAGE_LIMIT = 100
PULLPOINT_COOLDOWN_TIME = 0.75
def _local_datetime_or_none(value: str) -> dt.datetime | None:
"""Convert strings to datetimes, if invalid, return None."""
# Handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. Hikvision)
try:
ret = dt_util.parse_datetime(value)
except ValueError:
return None
if ret is not None:
return dt_util.as_local(ret)
return None
class EventManager:
"""ONVIF Event Manager."""
@@ -176,7 +195,10 @@ class EventManager:
# tns1:RuleEngine/CellMotionDetector/Motion
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
if not (parser := PARSERS.get(topic)):
try:
event = await onvif_parsers.parse(topic, unique_id, msg)
error = None
except onvif_parsers.errors.UnknownTopicError:
if topic not in UNHANDLED_TOPICS:
LOGGER.warning(
"%s: No registered handler for event from %s: %s",
@@ -186,10 +208,6 @@ class EventManager:
)
UNHANDLED_TOPICS.add(topic)
continue
try:
event = await parser(unique_id, msg)
error = None
except (AttributeError, KeyError) as e:
event = None
error = e
@@ -202,10 +220,26 @@ class EventManager:
error,
msg,
)
return
continue
self.get_uids_by_platform(event.platform).add(event.uid)
self._events[event.uid] = event
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
def get_uid(self, uid: str) -> Event | None:
"""Retrieve event for given id."""

View File

@@ -13,5 +13,9 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": ["onvif-zeep-async==4.0.4", "WSDiscovery==2.1.2"]
"requirements": [
"onvif-zeep-async==4.0.4",
"onvif_parsers==1.2.2",
"WSDiscovery==2.1.2"
]
}

View File

@@ -1,755 +0,0 @@
"""ONVIF event parsers."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
import dataclasses
import datetime
from typing import Any
from homeassistant.const import EntityCategory
from homeassistant.util import dt as dt_util
from homeassistant.util.decorator import Registry
from .models import Event
PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event | None]]] = (
Registry()
)
VIDEO_SOURCE_MAPPING = {
"vsconf": "VideoSourceToken",
}
def extract_message(msg: Any) -> tuple[str, Any]:
"""Extract the message content and the topic."""
return msg.Topic._value_1, msg.Message._value_1 # noqa: SLF001
def _normalize_video_source(source: str) -> str:
"""Normalize video source.
Some cameras do not set the VideoSourceToken correctly so we get duplicate
sensors, so we need to normalize it to the correct value.
"""
return VIDEO_SOURCE_MAPPING.get(source, source)
def local_datetime_or_none(value: str) -> datetime.datetime | None:
"""Convert strings to datetimes, if invalid, return None."""
# To handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. hikvision)
try:
ret = dt_util.parse_datetime(value)
except ValueError:
return None
if ret is not None:
return dt_util.as_local(ret)
return None
@PARSERS.register("tns1:VideoSource/MotionAlarm")
@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn")
async def async_parse_motion_alarm(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/MotionAlarm
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Motion Alarm",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService")
async def async_parse_image_too_blurry(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooBlurry/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Blurry",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService")
async def async_parse_image_too_dark(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooDark/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Dark",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService")
async def async_parse_image_too_bright(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooBright/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Bright",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService")
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService")
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService")
async def async_parse_scene_change(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/GlobalSceneChange/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Global Scene Change",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound")
async def async_parse_detected_sound(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:AudioAnalytics/Audio/DetectedSound
"""
audio_source = ""
audio_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "AudioSourceConfigurationToken":
audio_source = source.Value
if source.Name == "AudioAnalyticsConfigurationToken":
audio_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}",
"Detected Sound",
"binary_sensor",
"sound",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside")
async def async_parse_field_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/FieldDetector/ObjectsInside
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Field Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion")
async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/CellMotionDetector/Motion
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Cell Motion Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion")
async def async_parse_motion_region_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MotionRegionDetector/Motion
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Motion Region Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value in ["1", "true"],
)
@PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper")
async def async_parse_tamper_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/TamperDetector/Tamper
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Tamper Detection",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect")
async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Pet Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect")
async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Vehicle Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
_TAPO_EVENT_TEMPLATES: dict[str, Event] = {
"IsVehicle": Event(
uid="",
name="Vehicle Detection",
platform="binary_sensor",
device_class="motion",
),
"IsPeople": Event(
uid="", name="Person Detection", platform="binary_sensor", device_class="motion"
),
"IsPet": Event(
uid="", name="Pet Detection", platform="binary_sensor", device_class="motion"
),
"IsLineCross": Event(
uid="",
name="Line Detector Crossed",
platform="binary_sensor",
device_class="motion",
),
"IsTamper": Event(
uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper"
),
"IsIntrusion": Event(
uid="",
name="Intrusion Detection",
platform="binary_sensor",
device_class="safety",
),
}
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent")
@PARSERS.register("tns1:RuleEngine/PeopleDetector/People")
@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent")
async def async_parse_tplink_detector(uid: str, msg) -> Event | None:
"""Handle parsing tplink smart event messages.
Topic: tns1:RuleEngine/CellMotionDetector/Intrusion
Topic: tns1:RuleEngine/CellMotionDetector/LineCross
Topic: tns1:RuleEngine/CellMotionDetector/People
Topic: tns1:RuleEngine/CellMotionDetector/Tamper
Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent
Topic: tns1:RuleEngine/PeopleDetector/People
Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
for item in payload.Data.SimpleItem:
event_template = _TAPO_EVENT_TEMPLATES.get(item.Name)
if event_template is None:
continue
return dataclasses.replace(
event_template,
uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
value=item.Value == "true",
)
return None
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect")
async def async_parse_person_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Person Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect")
async def async_parse_face_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Face Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor")
async def async_parse_visitor_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/Visitor
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Visitor Detection",
"binary_sensor",
"occupancy",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package")
async def async_parse_package_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/Package
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Package Detection",
"binary_sensor",
"occupancy",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:Device/Trigger/DigitalInput")
async def async_parse_digital_input(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/Trigger/DigitalInput
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Digital Input",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:Device/Trigger/Relay")
async def async_parse_relay(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/Trigger/Relay
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Relay Triggered",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "active",
)
@PARSERS.register("tns1:Device/HardwareFailure/StorageFailure")
async def async_parse_storage_failure(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/HardwareFailure/StorageFailure
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Storage Failure",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/ProcessorUsage")
async def async_parse_processor_usage(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/ProcessorUsage
"""
topic, payload = extract_message(msg)
usage = float(payload.Data.SimpleItem[0].Value)
if usage <= 1:
usage *= 100
return Event(
f"{uid}_{topic}",
"Processor Usage",
"sensor",
None,
"percent",
int(usage),
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot")
async def async_parse_last_reboot(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastReboot
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Reboot",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReset")
async def async_parse_last_reset(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastReset
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Reset",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:Monitoring/Backup/Last")
async def async_parse_backup_last(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/Backup/Last
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Backup",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization")
async def async_parse_last_clock_sync(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Clock Synchronization",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:RecordingConfig/JobState")
async def async_parse_jobstate(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RecordingConfig/JobState
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Recording Job State",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "Active",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed")
async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/LineDetector/Crossed
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = source.Value
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Line Detector Crossed",
"sensor",
None,
None,
payload.Data.SimpleItem[0].Value,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter")
async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/CountAggregation/Counter
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Count Aggregation Counter",
"sensor",
None,
None,
payload.Data.SimpleItem[0].Value,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect")
async def async_parse_human_shape_detect(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:UserAlarm/IVA/HumanShapeDetect
"""
topic, payload = extract_message(msg)
video_source = ""
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
break
return Event(
f"{uid}_{topic}_{video_source}",
"Human Shape Detect",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)

View File

@@ -9,6 +9,6 @@
"iot_class": "local_push",
"loggers": ["openevsehttp"],
"quality_scale": "bronze",
"requirements": ["python-openevse-http==0.2.1"],
"requirements": ["python-openevse-http==0.2.5"],
"zeroconf": ["_openevse._tcp.local."]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.5"]
"requirements": ["renault-api==0.5.6"]
}

View File

@@ -2,35 +2,17 @@
import logging
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from .client import SatelClient
from .const import (
CONF_ARM_HOME_MODE,
CONF_DEVICE_PARTITIONS,
CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
CONF_PARTITION_NUMBER,
CONF_SWITCHABLE_OUTPUT_NUMBER,
CONF_SWITCHABLE_OUTPUTS,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DEFAULT_CONF_ARM_HOME_MODE,
DEFAULT_PORT,
DEFAULT_ZONE_TYPE,
DOMAIN,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
@@ -49,104 +31,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.SWITCH]
ZONE_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string,
}
)
EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})
PARTITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE): vol.In(
[1, 2, 3]
),
}
)
def is_alarm_code_necessary(value):
"""Check if alarm code must be configured."""
if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_CODE not in value:
raise vol.Invalid("You need to specify alarm code to use switchable_outputs")
return value
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_CODE): cv.string,
vol.Optional(CONF_DEVICE_PARTITIONS, default={}): {
vol.Coerce(int): PARTITION_SCHEMA
},
vol.Optional(CONF_ZONES, default={}): {vol.Coerce(int): ZONE_SCHEMA},
vol.Optional(CONF_OUTPUTS, default={}): {vol.Coerce(int): ZONE_SCHEMA},
vol.Optional(CONF_SWITCHABLE_OUTPUTS, default={}): {
vol.Coerce(int): EDITABLE_OUTPUT_SCHEMA
},
},
is_alarm_code_necessary,
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up Satel Integra from YAML."""
if config := hass_config.get(DOMAIN):
hass.async_create_task(_async_import(hass, config))
return True
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Process YAML import."""
if not hass.config_entries.async_entries(DOMAIN):
# Start import flow
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if result.get("type") == FlowResultType.ABORT:
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_cannot_connect",
breaks_in_ha_version="2026.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_cannot_connect",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Satel Integra",
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Satel Integra",
},
)
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> bool:

View File

@@ -13,7 +13,6 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryData,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
@@ -24,15 +23,11 @@ from homeassistant.helpers import config_validation as cv, selector
from .const import (
CONF_ARM_HOME_MODE,
CONF_DEVICE_PARTITIONS,
CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
CONF_PARTITION_NUMBER,
CONF_SWITCHABLE_OUTPUT_NUMBER,
CONF_SWITCHABLE_OUTPUTS,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DEFAULT_CONF_ARM_HOME_MODE,
DEFAULT_PORT,
DOMAIN,
@@ -53,6 +48,7 @@ CONNECTION_SCHEMA = vol.Schema(
}
)
CODE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CODE): cv.string,
@@ -143,97 +139,6 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=CONNECTION_SCHEMA, errors=errors
)
async def async_step_import(
self, import_config: dict[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by import."""
valid = await self.test_connection(
import_config[CONF_HOST], import_config.get(CONF_PORT, DEFAULT_PORT)
)
if valid:
subentries: list[ConfigSubentryData] = []
for partition_number, partition_data in import_config.get(
CONF_DEVICE_PARTITIONS, {}
).items():
subentries.append(
{
"subentry_type": SUBENTRY_TYPE_PARTITION,
"title": f"{partition_data[CONF_NAME]} ({partition_number})",
"unique_id": f"{SUBENTRY_TYPE_PARTITION}_{partition_number}",
"data": {
CONF_NAME: partition_data[CONF_NAME],
CONF_ARM_HOME_MODE: partition_data.get(
CONF_ARM_HOME_MODE, DEFAULT_CONF_ARM_HOME_MODE
),
CONF_PARTITION_NUMBER: partition_number,
},
}
)
for zone_number, zone_data in import_config.get(CONF_ZONES, {}).items():
subentries.append(
{
"subentry_type": SUBENTRY_TYPE_ZONE,
"title": f"{zone_data[CONF_NAME]} ({zone_number})",
"unique_id": f"{SUBENTRY_TYPE_ZONE}_{zone_number}",
"data": {
CONF_NAME: zone_data[CONF_NAME],
CONF_ZONE_NUMBER: zone_number,
CONF_ZONE_TYPE: zone_data.get(
CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
),
},
}
)
for output_number, output_data in import_config.get(
CONF_OUTPUTS, {}
).items():
subentries.append(
{
"subentry_type": SUBENTRY_TYPE_OUTPUT,
"title": f"{output_data[CONF_NAME]} ({output_number})",
"unique_id": f"{SUBENTRY_TYPE_OUTPUT}_{output_number}",
"data": {
CONF_NAME: output_data[CONF_NAME],
CONF_OUTPUT_NUMBER: output_number,
CONF_ZONE_TYPE: output_data.get(
CONF_ZONE_TYPE, BinarySensorDeviceClass.MOTION
),
},
}
)
for switchable_output_number, switchable_output_data in import_config.get(
CONF_SWITCHABLE_OUTPUTS, {}
).items():
subentries.append(
{
"subentry_type": SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
"title": f"{switchable_output_data[CONF_NAME]} ({switchable_output_number})",
"unique_id": f"{SUBENTRY_TYPE_SWITCHABLE_OUTPUT}_{switchable_output_number}",
"data": {
CONF_NAME: switchable_output_data[CONF_NAME],
CONF_SWITCHABLE_OUTPUT_NUMBER: switchable_output_number,
},
}
)
return self.async_create_entry(
title=import_config[CONF_HOST],
data={
CONF_HOST: import_config[CONF_HOST],
CONF_PORT: import_config.get(CONF_PORT, DEFAULT_PORT),
},
options={CONF_CODE: import_config.get(CONF_CODE)},
subentries=subentries,
)
return self.async_abort(reason="cannot_connect")
async def test_connection(self, host: str, port: int) -> bool:
"""Test a connection to the Satel alarm."""
controller = AsyncSatel(host, port, self.hass.loop)

View File

@@ -2,7 +2,6 @@
DEFAULT_CONF_ARM_HOME_MODE = 1
DEFAULT_PORT = 7094
DEFAULT_ZONE_TYPE = "motion"
DOMAIN = "satel_integra"
@@ -16,11 +15,7 @@ CONF_ZONE_NUMBER = "zone_number"
CONF_OUTPUT_NUMBER = "output_number"
CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number"
CONF_DEVICE_PARTITIONS = "partitions"
CONF_ARM_HOME_MODE = "arm_home_mode"
CONF_ZONE_TYPE = "type"
CONF_ZONES = "zones"
CONF_OUTPUTS = "outputs"
CONF_SWITCHABLE_OUTPUTS = "switchable_outputs"
ZONES = "zones"

View File

@@ -167,12 +167,6 @@
"message": "Cannot control switchable outputs because no user code is configured for this Satel Integra entry. Configure a code in the integration options to enable output control."
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your existing configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the `{domain}` YAML configuration from your configuration.yaml file and add the {integration_title} integration manually.",
"title": "YAML import failed due to a connection error"
}
},
"options": {
"step": {
"init": {

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "platinum",
"requirements": ["pysaunum==0.5.0"]
"requirements": ["pysaunum==0.6.0"]
}

View File

@@ -14,7 +14,7 @@ from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
"""Trigger for scene entity activations."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -17,13 +17,11 @@ from homeassistant.components import climate as FanState
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_TEMPERATURE,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
PRESET_HOME,
PRESET_NONE,
PRESET_SLEEP,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -40,7 +38,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import SwitchbotCloudData, SwitchBotCoordinator
from .const import DOMAIN, SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH
from .const import (
CLIMATE_PRESET_SCHEDULE,
DOMAIN,
SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH,
)
from .entity import SwitchBotCloudEntity
_LOGGER = getLogger(__name__)
@@ -206,6 +208,7 @@ RADIATOR_PRESET_MODE_MAP: dict[str, SmartRadiatorThermostatMode] = {
PRESET_BOOST: SmartRadiatorThermostatMode.FAST_HEATING,
PRESET_COMFORT: SmartRadiatorThermostatMode.COMFORT,
PRESET_HOME: SmartRadiatorThermostatMode.MANUAL,
CLIMATE_PRESET_SCHEDULE: SmartRadiatorThermostatMode.SCHEDULE,
}
RADIATOR_HA_PRESET_MODE_MAP = {
@@ -227,15 +230,10 @@ class SwitchBotCloudSmartRadiatorThermostat(SwitchBotCloudEntity, ClimateEntity)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_modes = [
PRESET_NONE,
PRESET_ECO,
PRESET_AWAY,
PRESET_BOOST,
PRESET_COMFORT,
PRESET_HOME,
PRESET_SLEEP,
]
_attr_preset_modes = list(RADIATOR_PRESET_MODE_MAP)
_attr_translation_key = "smart_radiator_thermostat"
_attr_preset_mode = PRESET_HOME
_attr_hvac_modes = [
@@ -300,7 +298,7 @@ class SwitchBotCloudSmartRadiatorThermostat(SwitchBotCloudEntity, ClimateEntity)
SmartRadiatorThermostatMode(mode)
]
if self.preset_mode in [PRESET_NONE, PRESET_AWAY]:
if self.preset_mode == PRESET_NONE:
self._attr_hvac_mode = HVACMode.OFF
else:
self._attr_hvac_mode = HVACMode.HEAT

View File

@@ -17,6 +17,9 @@ VACUUM_FAN_SPEED_STANDARD = "standard"
VACUUM_FAN_SPEED_STRONG = "strong"
VACUUM_FAN_SPEED_MAX = "max"
CLIMATE_PRESET_SCHEDULE = "schedule"
AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
SMART_RADIATOR_THERMOSTAT_AFTER_COMMAND_REFRESH = 30

View File

@@ -8,6 +8,17 @@
"default": "mdi:chevron-left-box"
}
},
"climate": {
"smart_radiator_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"schedule": "mdi:clock-outline"
}
}
}
}
},
"fan": {
"air_purifier": {
"default": "mdi:air-purifier",

View File

@@ -26,6 +26,17 @@
"name": "Previous"
}
},
"climate": {
"smart_radiator_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"schedule": "Schedule"
}
}
}
}
},
"fan": {
"air_purifier": {
"state_attributes": {

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiotankerkoenig"],
"quality_scale": "platinum",
"requirements": ["aiotankerkoenig==0.4.2"]
"requirements": ["aiotankerkoenig==0.5.1"]
}

View File

@@ -62,8 +62,8 @@ _LOGGER = logging.getLogger(__name__)
DESCRIPTION_PLACEHOLDERS: dict[str, str] = {
"botfather_username": "@BotFather",
"botfather_url": "https://t.me/botfather",
"getidsbot_username": "@GetIDs Bot",
"getidsbot_url": "https://t.me/getidsbot",
"id_bot_username": "@id_bot",
"id_bot_url": "https://t.me/id_bot",
"socks_url": "socks5://username:password@proxy_ip:proxy_port",
# used in advanced settings section
"default_api_endpoint": DEFAULT_API_ENDPOINT,
@@ -611,10 +611,15 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow):
errors["base"] = "chat_not_found"
service: TelegramNotificationService = self._get_entry().runtime_data
description_placeholders = DESCRIPTION_PLACEHOLDERS.copy()
description_placeholders["bot_username"] = f"@{service.bot.username}"
description_placeholders["bot_url"] = f"https://t.me/{service.bot.username}"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}),
description_placeholders=DESCRIPTION_PLACEHOLDERS,
description_placeholders=description_placeholders,
errors=errors,
)

View File

@@ -105,7 +105,7 @@
"data_description": {
"chat_id": "ID representing the user or group chat to which messages can be sent."
},
"description": "To get your chat ID, follow these steps:\n\n1. Open Telegram and start a chat with [{getidsbot_username}]({getidsbot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `id` field of the bot's response.",
"description": "Before you proceed, send any message to your bot: [{bot_username}]({bot_url}). This is required because Telegram prevents bots from initiating chats with users.\n\nThen follow these steps to get your chat ID:\n\n1. Open Telegram and start a chat with [{id_bot_username}]({id_bot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `ID` field of the bot's response.",
"title": "Add chat"
}
}

View File

@@ -14,7 +14,7 @@ from .const import DOMAIN
class TextChangedTrigger(EntityTriggerBase):
"""Trigger for text entity when its content changes."""
_domain = DOMAIN
_domains = {DOMAIN}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["uhooapi==1.2.6"]
"requirements": ["uhooapi==1.2.8"]
}

View File

@@ -23,6 +23,7 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API
STATE_ON,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
@@ -63,7 +64,6 @@ SERVICE_STOP = "stop"
DEFAULT_NAME = "Vacuum cleaner robot"
ISSUE_SEGMENTS_CHANGED = "segments_changed"
ISSUE_SEGMENTS_MAPPING_NOT_CONFIGURED = "segments_mapping_not_configured"
_BATTERY_DEPRECATION_IGNORED_PLATFORMS = ("template",)
@@ -438,7 +438,14 @@ class StateVacuumEntity(
)
options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {})
area_mapping: dict[str, list[str]] = options.get("area_mapping", {})
area_mapping: dict[str, list[str]] | None = options.get("area_mapping")
if area_mapping is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="area_mapping_not_configured",
translation_placeholders={"entity_id": self.entity_id},
)
# We use a dict to preserve the order of segments.
segment_ids: dict[str, None] = {}

View File

@@ -89,6 +89,11 @@
}
}
},
"exceptions": {
"area_mapping_not_configured": {
"message": "Area mapping is not configured for `{entity_id}`. Configure the segment-to-area mapping before using this action."
}
},
"issues": {
"segments_changed": {
"description": "",

View File

@@ -35,9 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
):
store: Store[list[dict[str, Any]]] = Store(hass, 1, DOMAIN)
coordinator = VizioAppsDataUpdateCoordinator(hass, entry, store)
await coordinator.async_config_entry_first_refresh()
coordinator = VizioAppsDataUpdateCoordinator(hass, store)
await coordinator.async_setup()
hass.data[DOMAIN][CONF_APPS] = coordinator
await coordinator.async_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -53,7 +54,8 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
):
hass.data[DOMAIN].pop(CONF_APPS, None)
if coordinator := hass.data[DOMAIN].pop(CONF_APPS, None):
await coordinator.async_shutdown()
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)

View File

@@ -9,7 +9,6 @@ from typing import Any
from pyvizio.const import APPS
from pyvizio.util import gen_apps_list_from_url
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import Store
@@ -23,19 +22,16 @@ _LOGGER = logging.getLogger(__name__)
class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Define an object to hold Vizio app config data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
store: Store[list[dict[str, Any]]],
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
config_entry=None,
name=DOMAIN,
update_interval=timedelta(days=1),
)
@@ -43,8 +39,9 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]
self.fail_threshold = 10
self.store = store
async def _async_setup(self) -> None:
"""Refresh data for the first time when a config entry is setup."""
async def async_setup(self) -> None:
"""Load initial data from storage and register shutdown."""
await self.async_register_shutdown()
self.data = await self.store.async_load() or APPS
async def _async_update_data(self) -> list[dict[str, Any]]:

View File

@@ -111,7 +111,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bo
# Migrate unique_id from `xbox` to account xuid and
# change generic entry name to user's gamertag
try:
own = await client.people.get_friends_by_xuid(client.xuid)
own = await client.people.get_friend_by_xuid(client.xuid)
except TimeoutException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,

View File

@@ -74,7 +74,7 @@ class OAuth2FlowHandler(
client = XboxLiveClient(auth)
me = await client.people.get_friends_by_xuid(client.xuid)
me = await client.people.get_friend_by_xuid(client.xuid)
await self.async_set_unique_id(client.xuid)

View File

@@ -213,10 +213,10 @@ class XboxPresenceCoordinator(XboxBaseCoordinator[XboxData]):
async def update_data(self) -> XboxData:
"""Fetch presence data."""
batch = await self.client.people.get_friends_by_xuid(self.client.xuid)
me = await self.client.people.get_friend_by_xuid(self.client.xuid)
friends = await self.client.people.get_friends_own()
presence_data = {self.client.xuid: batch.people[0]}
presence_data = {self.client.xuid: me.people[0]}
presence_data.update(
{
friend.xuid: friend

View File

@@ -14,7 +14,7 @@
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["python-xbox==0.1.3"],
"requirements": ["python-xbox==0.2.0"],
"ssdp": [
{
"manufacturer": "Microsoft Corporation",

View File

@@ -28,7 +28,7 @@ from .helpers import (
)
from .models import ZwaveJSConfigEntry
KEYS_TO_REDACT = {"homeId", "location"}
KEYS_TO_REDACT = {"homeId", "location", "dsk"}
VALUES_TO_REDACT = (
ZwaveValueMatcher(property_="userCode", command_class=CommandClass.USER_CODE),

View File

@@ -381,3 +381,20 @@ class DependencyError(HomeAssistantError):
f"Could not setup dependencies: {', '.join(failed_dependencies)}",
)
self.failed_dependencies = failed_dependencies
class UnsupportedStorageVersionError(HomeAssistantError):
"""Raised when a storage file has a newer major version than expected."""
def __init__(
self, storage_key: str, found_version: int, max_supported_version: int
) -> None:
"""Initialize error."""
super().__init__(
f"Storage file {storage_key} has version {found_version}"
f" which is newer than the max supported version {max_supported_version};"
" upgrade Home Assistant or restore from a backup",
)
self.storage_key = storage_key
self.found_version = found_version
self.max_supported_version = max_supported_version

View File

@@ -212,6 +212,11 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "govee_ble",
"local_name": "GVH5110*",
},
{
"connectable": False,
"domain": "govee_ble",
"local_name": "GV5140*",
},
{
"connectable": False,
"domain": "govee_ble",

View File

@@ -7338,6 +7338,12 @@
}
}
},
"ubisys": {
"name": "Ubisys",
"iot_standards": [
"zigbee"
]
},
"ubiwizz": {
"name": "Ubiwizz",
"integration_type": "virtual",

View File

@@ -447,7 +447,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
EventAreaRegistryUpdatedData(action="reorder", area_id=None),
)
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the area registry."""
self._async_setup_cleanup()
@@ -549,10 +549,10 @@ def async_get(hass: HomeAssistant) -> AreaRegistry:
return AreaRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load area registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)
@callback

View File

@@ -77,7 +77,7 @@ class CategoryRegistryStore(Store[CategoryRegistryStoreData]):
) -> CategoryRegistryStoreData:
"""Migrate to the new version."""
if old_major_version > STORAGE_VERSION_MAJOR:
raise ValueError("Can't migrate to future version")
raise NotImplementedError
if old_major_version == 1:
if old_minor_version < 2:
@@ -204,7 +204,7 @@ class CategoryRegistry(BaseRegistry[CategoryRegistryStoreData]):
return new
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the category registry."""
data = await self._store.async_load()
category_entries: dict[str, dict[str, CategoryEntry]] = {}
@@ -265,7 +265,7 @@ def async_get(hass: HomeAssistant) -> CategoryRegistry:
return CategoryRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load category registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)

View File

@@ -1461,7 +1461,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
)
self.async_schedule_save()
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the device registry."""
async_setup_cleanup(self.hass, self)
@@ -1706,10 +1706,10 @@ def async_get(hass: HomeAssistant) -> DeviceRegistry:
return DeviceRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load device registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)
@callback

View File

@@ -1678,7 +1678,7 @@ class EntityRegistry(BaseRegistry):
new_options[domain] = options
return self._async_update_entity(entity_id, options=new_options)
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the entity registry."""
_async_setup_cleanup(self.hass, self)
_async_setup_entity_restore(self.hass, self)
@@ -1945,10 +1945,10 @@ def async_get(hass: HomeAssistant) -> EntityRegistry:
return EntityRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load entity registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)
@callback

View File

@@ -94,7 +94,7 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]):
) -> FloorRegistryStoreData:
"""Migrate to the new version."""
if old_major_version > STORAGE_VERSION_MAJOR:
raise ValueError("Can't migrate to future version")
raise NotImplementedError
if old_major_version == 1:
if old_minor_version < 2:
@@ -307,7 +307,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
_EventFloorRegistryUpdatedData_Reorder(action="reorder"),
)
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the floor registry."""
data = await self._store.async_load()
floors = FloorRegistryItems()
@@ -353,7 +353,7 @@ def async_get(hass: HomeAssistant) -> FloorRegistry:
return FloorRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load floor registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import importlib
import logging
import sys
@@ -53,11 +52,10 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType:
if isinstance(ex, ModuleNotFoundError):
failure_cache[name] = True
import_future.set_exception(ex)
with suppress(BaseException):
# Set the exception retrieved flag on the future since
# it will never be retrieved unless there
# are concurrent calls
import_future.result()
# Set the exception retrieved flag on the future since
# it will never be retrieved unless there
# are concurrent calls
import_future.exception()
raise
finally:
del import_futures[name]

View File

@@ -251,7 +251,7 @@ class IssueRegistry(BaseRegistry):
"""
self._store.make_read_only()
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the issue registry."""
data = await self._store.async_load()
@@ -314,12 +314,17 @@ def async_get(hass: HomeAssistant) -> IssueRegistry:
return IssueRegistry(hass)
async def async_load(hass: HomeAssistant, *, read_only: bool = False) -> None:
async def async_load(
hass: HomeAssistant,
*,
read_only: bool = False,
load_empty: bool = False,
) -> None:
"""Load issue registry."""
ir = async_get(hass)
if read_only: # only used in for check config script
ir.make_read_only()
return await ir.async_load()
await ir.async_load(load_empty=load_empty)
@callback

View File

@@ -80,7 +80,7 @@ class LabelRegistryStore(Store[LabelRegistryStoreData]):
) -> LabelRegistryStoreData:
"""Migrate to the new version."""
if old_major_version > STORAGE_VERSION_MAJOR:
raise ValueError("Can't migrate to future version")
raise NotImplementedError
if old_major_version == 1:
if old_minor_version < 2:
@@ -224,7 +224,7 @@ class LabelRegistry(BaseRegistry[LabelRegistryStoreData]):
return new
async def async_load(self) -> None:
async def _async_load(self) -> None:
"""Load the label registry."""
data = await self._store.async_load()
labels = NormalizedNameBaseRegistryItems[LabelEntry]()
@@ -270,7 +270,7 @@ def async_get(hass: HomeAssistant) -> LabelRegistry:
return LabelRegistry(hass)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load label registry."""
assert DATA_REGISTRY not in hass.data
await async_get(hass).async_load()
await async_get(hass).async_load(load_empty=load_empty)

View File

@@ -77,6 +77,19 @@ class BaseRegistry[_StoreDataT: Mapping[str, Any] | Sequence[Any]](ABC):
delay = SAVE_DELAY if self.hass.state is CoreState.running else SAVE_DELAY_LONG
self._store.async_delay_save(self._data_to_save, delay)
async def async_load(self, *, load_empty: bool = False) -> None:
"""Load the registry.
Optionally set the store to load empty and become read-only.
"""
if load_empty:
self._store.set_load_empty()
await self._async_load()
@abstractmethod
async def _async_load(self) -> None:
"""Load the registry."""
@abstractmethod
def _data_to_save(self) -> _StoreDataT:
"""Return data of registry to store in a file."""

View File

@@ -9,7 +9,7 @@ from typing import Any, Self, cast
from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, State, callback, valid_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import json_loads
@@ -95,9 +95,12 @@ class StoredState:
)
async def async_load(hass: HomeAssistant) -> None:
async def async_load(hass: HomeAssistant, *, load_empty: bool = False) -> None:
"""Load the restore state task."""
await async_get(hass).async_setup()
data = async_get(hass)
if load_empty:
data.set_load_empty()
await data.async_setup()
@callback
@@ -124,6 +127,10 @@ class RestoreStateData:
self.last_states: dict[str, StoredState] = {}
self.entities: dict[str, RestoreEntity] = {}
def set_load_empty(self) -> None:
"""Set the store to load empty and become read-only."""
self.store.set_load_empty()
async def async_setup(self) -> None:
"""Set up up the instance of this data helper."""
await self.async_load()
@@ -139,6 +146,8 @@ class RestoreStateData:
"""Load the instance of this data helper."""
try:
stored_states = await self.store.async_load()
except UnsupportedStorageVersionError:
raise
except HomeAssistantError as exc:
_LOGGER.error("Error loading last states", exc_info=exc)
stored_states = None

View File

@@ -28,7 +28,7 @@ from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, UnsupportedStorageVersionError
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.file import WriteError, write_utf8_file, write_utf8_file_atomic
@@ -239,6 +239,7 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
*,
atomic_writes: bool = False,
encoder: type[JSONEncoder] | None = None,
max_readable_version: int | None = None,
minor_version: int = 1,
read_only: bool = False,
serialize_in_event_loop: bool = True,
@@ -246,6 +247,10 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
"""Initialize storage class.
Args:
max_readable_version: Maximum major version that can be read. Defaults
to version. Set higher than version to support forward compatibility,
allowing reading data written by newer versions (e.g., after downgrade).
serialize_in_event_loop: Whether to serialize data in the event loop.
Set to True (default) if data passed to async_save and data produced by
data_func passed to async_delay_save needs to be serialized in the event
@@ -273,6 +278,10 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
self._encoder = encoder
self._atomic_writes = atomic_writes
self._read_only = read_only
self._load_empty = False
self._max_readable_version = (
max_readable_version if max_readable_version is not None else version
)
self._next_write_time = 0.0
self._manager = get_internal_store_manager(hass)
self._serialize_in_event_loop = serialize_in_event_loop
@@ -289,6 +298,14 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
"""
self._read_only = True
def set_load_empty(self) -> None:
"""Set the store to load empty data and become read-only.
When set, the store will skip loading data from disk and return None,
while also becoming read-only to preserve on-disk data untouched.
"""
self._load_empty = True
async def async_load(self) -> _T | None:
"""Load data.
@@ -328,6 +345,12 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
async def _async_load_data(self):
"""Load the data."""
# When load_empty is set, skip loading storage files and use empty
# data while preserving the on-disk files untouched.
if self._load_empty:
self.make_read_only()
return None
# Check if we have a pending write
if self._data is not None:
data = self._data
@@ -415,6 +438,10 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]:
):
stored = data["data"]
else:
if data["version"] > self._max_readable_version:
raise UnsupportedStorageVersionError(
self.key, data["version"], self._max_readable_version
)
_LOGGER.info(
"Migrating %s storage from %s.%s to %s.%s",
self.key,

View File

@@ -336,7 +336,7 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain: str
_domains: set[str]
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST
@override
@@ -386,11 +386,11 @@ class EntityTriggerBase(Trigger):
)
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
"""Filter entities of these domains."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == self._domain
if split_entity_id(entity_id)[0] in self._domains
}
@override
@@ -792,7 +792,7 @@ def make_entity_target_state_trigger(
class CustomTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
_domains = {domain}
_to_states = to_states_set
return CustomTrigger
@@ -806,7 +806,7 @@ def make_entity_transition_trigger(
class CustomTrigger(EntityTransitionTriggerBase):
"""Trigger for conditional entity state changes."""
_domain = domain
_domains = {domain}
_from_states = from_states
_to_states = to_states
@@ -821,7 +821,7 @@ def make_entity_origin_state_trigger(
class CustomTrigger(EntityOriginStateTriggerBase):
"""Trigger for entity "from state" changes."""
_domain = domain
_domains = {domain}
_from_state = from_state
return CustomTrigger
@@ -835,7 +835,7 @@ def make_entity_numerical_state_attribute_changed_trigger(
class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for numerical state attribute changes."""
_domain = domain
_domains = {domain}
_attribute = attribute
return CustomTrigger
@@ -849,7 +849,7 @@ def make_entity_numerical_state_attribute_crossed_threshold_trigger(
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
"""Trigger for numerical state attribute changes."""
_domain = domain
_domains = {domain}
_attribute = attribute
return CustomTrigger
@@ -863,7 +863,7 @@ def make_entity_target_state_attribute_trigger(
class CustomTrigger(EntityTargetStateAttributeTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
_domains = {domain}
_attribute = attribute
_attribute_to_state = to_state

View File

@@ -40,7 +40,7 @@ habluetooth==5.8.0
hass-nabucasa==1.15.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260226.0
home-assistant-frontend==20260302.0
home-assistant-intents==2026.2.13
httpx==0.28.1
ifaddr==0.2.0

21
requirements_all.txt generated
View File

@@ -416,7 +416,7 @@ aioswitcher==6.1.0
aiosyncthing==0.7.1
# homeassistant.components.tankerkoenig
aiotankerkoenig==0.4.2
aiotankerkoenig==0.5.1
# homeassistant.components.tedee
aiotedee==0.2.25
@@ -1223,7 +1223,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260226.0
home-assistant-frontend==20260302.0
# homeassistant.components.conversation
home-assistant-intents==2026.2.13
@@ -1374,7 +1374,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2026.2.25.165736
knx-frontend==2026.3.2.183756
# homeassistant.components.konnected
konnected==1.2.0
@@ -1681,6 +1681,9 @@ onedrive-personal-sdk==0.1.4
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -2233,7 +2236,7 @@ pylitejet==0.6.3
pylitterbot==2025.1.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.26.0
pylutron-caseta==0.27.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -2427,7 +2430,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.saunum
pysaunum==0.5.0
pysaunum==0.6.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2605,7 +2608,7 @@ python-open-router==0.3.3
python-opendata-transport==0.5.0
# homeassistant.components.openevse
python-openevse-http==0.2.1
python-openevse-http==0.2.5
# homeassistant.components.opensky
python-opensky==1.0.1
@@ -2657,7 +2660,7 @@ python-telegram-bot[socks]==22.1
python-vlc==3.0.18122
# homeassistant.components.xbox
python-xbox==0.1.3
python-xbox==0.2.0
# homeassistant.components.egardia
pythonegardia==1.0.52
@@ -2790,7 +2793,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.5
renault-api==0.5.6
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -3142,7 +3145,7 @@ typedmonarchmoney==0.7.0
uasiren==0.0.1
# homeassistant.components.uhoo
uhooapi==1.2.6
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.2.2

View File

@@ -401,7 +401,7 @@ aioswitcher==6.1.0
aiosyncthing==0.7.1
# homeassistant.components.tankerkoenig
aiotankerkoenig==0.4.2
aiotankerkoenig==0.5.1
# homeassistant.components.tedee
aiotedee==0.2.25
@@ -1084,7 +1084,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260226.0
home-assistant-frontend==20260302.0
# homeassistant.components.conversation
home-assistant-intents==2026.2.13
@@ -1211,7 +1211,7 @@ kegtron-ble==1.0.2
knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2026.2.25.165736
knx-frontend==2026.3.2.183756
# homeassistant.components.konnected
konnected==1.2.0
@@ -1467,6 +1467,9 @@ onedrive-personal-sdk==0.1.4
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
# homeassistant.components.opengarage
open-garage==0.2.0
@@ -1907,7 +1910,7 @@ pylitejet==0.6.3
pylitterbot==2025.1.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.26.0
pylutron-caseta==0.27.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -2068,7 +2071,7 @@ pyrympro==0.0.9
pysabnzbd==1.1.1
# homeassistant.components.saunum
pysaunum==0.5.0
pysaunum==0.6.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2204,7 +2207,7 @@ python-open-router==0.3.3
python-opendata-transport==0.5.0
# homeassistant.components.openevse
python-openevse-http==0.2.1
python-openevse-http==0.2.5
# homeassistant.components.opensky
python-opensky==1.0.1
@@ -2250,7 +2253,7 @@ python-technove==2.0.0
python-telegram-bot[socks]==22.1
# homeassistant.components.xbox
python-xbox==0.1.3
python-xbox==0.2.0
# homeassistant.components.uptime_kuma
pythonkuma==0.5.0
@@ -2362,7 +2365,7 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.5
renault-api==0.5.6
# homeassistant.components.renson
renson-endura-delta==1.7.2
@@ -2645,7 +2648,7 @@ typedmonarchmoney==0.7.0
uasiren==0.0.1
# homeassistant.components.uhoo
uhooapi==1.2.6
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==10.2.2

View File

@@ -2,5 +2,59 @@
"tilt_eq": {
"enabled": true,
"intensity": 0
},
"user_eq": {
"enabled": true,
"bands": [
{
"index": 0,
"filter": "LOWSHELF",
"freq": 80,
"gain": 0.0,
"q": 0.8
},
{
"index": 1,
"filter": "PEAKING",
"freq": 120,
"gain": 0.0,
"q": 1.24
},
{
"index": 2,
"filter": "PEAKING",
"freq": 315,
"gain": 0.0,
"q": 1.24
},
{
"index": 3,
"filter": "PEAKING",
"freq": 800,
"gain": 0.0,
"q": 1.24
},
{
"index": 4,
"filter": "PEAKING",
"freq": 2000,
"gain": 0.0,
"q": 1.24
},
{
"index": 5,
"filter": "PEAKING",
"freq": 5000,
"gain": 0.0,
"q": 1.24
},
{
"index": 6,
"filter": "HIGHSHELF",
"freq": 8000,
"gain": 0.0,
"q": 0.8
}
]
}
}

View File

@@ -48,6 +48,55 @@
'state': 'off',
})
# ---
# name: test_all_entities[switch.cambridge_audio_cxnv2_equalizer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.cambridge_audio_cxnv2_equalizer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Equalizer',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Equalizer',
'platform': 'cambridge_audio',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'equalizer',
'unique_id': '0020c2d8-equalizer',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.cambridge_audio_cxnv2_equalizer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Cambridge Audio CXNv2 Equalizer',
}),
'context': <ANY>,
'entity_id': 'switch.cambridge_audio_cxnv2_equalizer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[switch.cambridge_audio_cxnv2_pre_amp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -58,3 +58,62 @@ async def test_setting_value(
blocking=True,
)
mock_stream_magic_client.set_early_update.assert_called_once_with(False)
async def test_equalizer_switch(
hass: HomeAssistant,
mock_stream_magic_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test equalizer switch."""
await setup_integration(hass, mock_config_entry)
# Test turning equalizer on
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{
ATTR_ENTITY_ID: "switch.cambridge_audio_cxnv2_equalizer",
},
blocking=True,
)
mock_stream_magic_client.set_equalizer_mode.assert_called_once_with(True)
mock_stream_magic_client.set_equalizer_mode.reset_mock()
# Test turning equalizer off
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{
ATTR_ENTITY_ID: "switch.cambridge_audio_cxnv2_equalizer",
},
blocking=True,
)
mock_stream_magic_client.set_equalizer_mode.assert_called_once_with(False)
async def test_equalizer_switch_without_user_eq(
hass: HomeAssistant,
mock_stream_magic_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that equalizer switch entity is not created when user_eq is None."""
# Set user_eq to None to simulate a device without EQ support
mock_stream_magic_client.audio.user_eq = None
await setup_integration(hass, mock_config_entry)
# Verify the equalizer switch entity was not created
assert entity_registry.async_get("switch.cambridge_audio_cxnv2_equalizer") is None
# Verify other switch entities still exist
assert entity_registry.async_get("switch.cambridge_audio_cxnv2_pre_amp") is not None
assert (
entity_registry.async_get("switch.cambridge_audio_cxnv2_early_update")
is not None
)
assert (
entity_registry.async_get("switch.cambridge_audio_cxnv2_room_correction")
is not None
)

View File

@@ -16,6 +16,7 @@ from homeassistant.components.conversation import (
async_get_chat_log,
)
from homeassistant.components.conversation.const import HOME_ASSISTANT_AGENT
from homeassistant.components.conversation.models import ConversationResult
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
@@ -173,6 +174,36 @@ async def test_http_api_wrong_data(
assert resp.status == HTTPStatus.BAD_REQUEST
async def test_http_processing_intent_with_device_satellite_ids(
hass: HomeAssistant,
init_components,
hass_client: ClientSessionGenerator,
) -> None:
"""Test processing intent via HTTP API with both device_id and satellite_id."""
client = await hass_client()
mock_result = intent.IntentResponse(language=hass.config.language)
mock_result.async_set_speech("test")
with patch(
"homeassistant.components.conversation.http.async_converse",
return_value=ConversationResult(response=mock_result),
) as mock_converse:
resp = await client.post(
"/api/conversation/process",
json={
"text": "test",
"device_id": "test-device-id",
"satellite_id": "test-satellite-id",
},
)
assert resp.status == HTTPStatus.OK
mock_converse.assert_called_once()
call_kwargs = mock_converse.call_args[1]
assert call_kwargs["device_id"] == "test-device-id"
assert call_kwargs["satellite_id"] == "test-satellite-id"
@pytest.mark.parametrize(
"payload",
[
@@ -221,6 +252,38 @@ async def test_ws_api(
assert msg["result"]["response"]["data"]["code"] == "no_intent_match"
async def test_ws_api_with_device_satellite_ids(
hass: HomeAssistant,
init_components,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the Websocket conversation API with both device_id and satellite_id."""
client = await hass_ws_client(hass)
mock_result = intent.IntentResponse(language=hass.config.language)
mock_result.async_set_speech("test")
with patch(
"homeassistant.components.conversation.http.async_converse",
return_value=ConversationResult(response=mock_result),
) as mock_converse:
await client.send_json_auto_id(
{
"type": "conversation/process",
"text": "test",
"device_id": "test-device-id",
"satellite_id": "test-satellite-id",
}
)
msg = await client.receive_json()
assert msg["success"]
mock_converse.assert_called_once()
call_kwargs = mock_converse.call_args[1]
assert call_kwargs["device_id"] == "test-device-id"
assert call_kwargs["satellite_id"] == "test-satellite-id"
@pytest.mark.parametrize("agent_id", AGENT_ID_OPTIONS)
async def test_ws_prepare(
hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id

View File

@@ -1,19 +1,64 @@
"""eafm fixtures."""
from unittest.mock import patch
from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.eafm.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_get_stations():
def mock_get_stations() -> Generator[AsyncMock]:
"""Mock aioeafm.get_stations."""
with patch("homeassistant.components.eafm.config_flow.get_stations") as patched:
patched.return_value = [
{"label": "My station", "stationReference": "L12345", "RLOIid": "R12345"}
]
yield patched
@pytest.fixture
def mock_get_station():
def mock_get_station(initial_value: dict[str, Any]) -> Generator[AsyncMock]:
"""Mock aioeafm.get_station."""
with patch("homeassistant.components.eafm.coordinator.get_station") as patched:
patched.return_value = initial_value
yield patched
@pytest.fixture
def initial_value() -> dict[str, Any]:
"""Mock aioeafm.get_station."""
return {
"label": "My station",
"measures": [
{
"@id": "really-long-unique-id",
"label": "York Viking Recorder - level-stage-i-15_min----",
"qualifier": "Stage",
"parameterName": "Water Level",
"latestReading": {"value": 5},
"stationReference": "L1234",
"unit": "http://qudt.org/1.1/vocab/unit#Meter",
"unitName": "m",
}
],
}
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Create a dummy config entry for testing."""
entry = MockConfigEntry(
version=1,
domain=DOMAIN,
entry_id="VikingRecorder1234",
data={"station": "L1234"},
title="Viking Recorder",
)
entry.add_to_hass(hass)
return entry

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