Compare commits

..

26 Commits

Author SHA1 Message Date
J. Nick Koston
991b8d2040 Bump aioshelly to 13.19.0 (#156902) 2025-11-19 17:52:55 -06:00
J. Nick Koston
43fadbf6b4 Bump aioshelly to 13.18.0 (#156887) 2025-11-19 19:00:10 +02:00
Maciej Bieniek
ca79d37135 Use native_value property instead of _attr_native_value in the Brother integration (#156878) 2025-11-19 16:06:11 +01:00
Paul Bottein
df8ef15535 Add reorder floors and areas websocket command (#156802)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-19 09:58:07 -05:00
Maciej Bieniek
249c1530d0 Address comments for Brother tests (#156877) 2025-11-19 15:06:27 +01:00
Maciej Bieniek
081b769abc Use Brother printer model as model_id (#156876) 2025-11-19 14:44:22 +01:00
Josef Zweck
b8b101d747 Lamarzocco fix websocket reconnect issue (#156786)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-11-19 13:06:29 +01:00
Sebastian Schneider
a19be192e0 Bump aiounifi to 88 (#156867) 2025-11-19 13:04:20 +01:00
Josef Zweck
92da82a200 Bump onedrive-personal-sdk to 0.0.17 (#156865) 2025-11-19 13:03:37 +01:00
Paul Bottein
820ba1dfba Add system-level frontend data storage (#155945) 2025-11-19 06:59:34 -05:00
Ludovic BOUÉ
63c8962f09 Add Matter mock lock fixture (#156862) 2025-11-19 12:50:58 +01:00
dependabot[bot]
c1a6996549 Bump github/codeql-action from 4.31.3 to 4.31.4 (#156850)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-19 12:46:32 +01:00
epenet
05253841af Auto-generate fixture list in Tuya tests (#156858) 2025-11-19 12:38:11 +01:00
Heindrich Paul
f2ef0503a0 Adding new sensors to the cat litter box (#156054)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-19 12:32:54 +01:00
puddly
938da38fc3 Bump universal-silabs-flasher to 0.1.2 (#156849) 2025-11-19 10:46:56 +01:00
Niracler
9311a87bf5 Refactor Sunricher DALI integration to use direct device callbacks (#155315) 2025-11-19 09:47:45 +01:00
Louis
b45294ded3 unifi: Add wired client link speed sensor and related tests (#155086)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
2025-11-19 09:26:26 +01:00
Andre Lengwenus
82d3190016 Bump pypck to 0.9.5 (#156847) 2025-11-19 06:52:29 +01:00
omrishiv
d8cbcc1977 Bump pylutron-caseta to 0.26.0 (#156825)
Signed-off-by: omrishiv <327609+omrishiv@users.noreply.github.com>
2025-11-18 23:01:34 +01:00
Raj Laud
4b69543515 Add support for Victron bluetooth low energy devices (#148043)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 21:12:48 +01:00
Thomas55555
97ef4a35b9 Bump aioautomower to 2.7.1 (#156826) 2025-11-18 20:32:47 +01:00
Dan Raper
f782c78650 Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-18 19:52:17 +01:00
Abílio Costa
139ed34c74 Properly mock integrations' file_path (#156813) 2025-11-18 18:42:35 +01:00
Andre Lengwenus
7f14d013ac Strict typing for lcn integration (#156800) 2025-11-18 18:26:24 +01:00
Artur Pragacz
963e27dda4 Send snapshot analytics for device database in dev (#155717) 2025-11-18 16:15:27 +00:00
Yuxin Wang
b8e3d57fea Deprecate useless sensors in APCUPSD integration (#151525) 2025-11-18 17:09:38 +01:00
102 changed files with 6189 additions and 1470 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: "/language:python"

2
CODEOWNERS generated
View File

@@ -1736,6 +1736,8 @@ build.json @home-assistant/supervisor
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW

View File

@@ -0,0 +1,5 @@
{
"domain": "victron",
"name": "Victron",
"integrations": ["victron_ble", "victron_remote_monitoring"]
}

View File

@@ -6,9 +6,8 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
@@ -20,7 +19,7 @@ from .analytics import (
EntityAnalyticsModifications,
async_devices_payload,
)
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, PREFERENCE_SCHEMA
from .http import AnalyticsDevicesView
__all__ = [
@@ -43,28 +42,9 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool:
# Load stored data
await analytics.load()
@callback
def start_schedule(_event: Event) -> None:
async def start_schedule(_event: Event) -> None:
"""Start the send schedule after the started event."""
# Wait 15 min after started
async_call_later(
hass,
900,
HassJob(
analytics.send_analytics,
name="analytics schedule",
cancel_on_shutdown=True,
),
)
# Send every day
async_track_time_interval(
hass,
analytics.send_analytics,
INTERVAL,
name="analytics daily",
cancel_on_shutdown=True,
)
await analytics.async_schedule()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
@@ -111,7 +91,7 @@ async def websocket_analytics_preferences(
analytics = hass.data[DATA_COMPONENT]
await analytics.save_preferences(preferences)
await analytics.send_analytics()
await analytics.async_schedule()
connection.send_result(
msg["id"],

View File

@@ -7,6 +7,8 @@ from asyncio import timeout
from collections.abc import Awaitable, Callable, Iterable, Mapping
from dataclasses import asdict as dataclass_asdict, dataclass, field
from datetime import datetime
import random
import time
from typing import Any, Protocol
import uuid
@@ -31,10 +33,18 @@ from homeassistant.const import (
BASE_PLATFORMS,
__version__ as HA_VERSION,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import (
CALLBACK_TYPE,
HassJob,
HomeAssistant,
ReleaseChannel,
callback,
get_release_channel,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
@@ -51,6 +61,7 @@ from homeassistant.setup import async_get_loaded_integrations
from .const import (
ANALYTICS_ENDPOINT_URL,
ANALYTICS_ENDPOINT_URL_DEV,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
ATTR_ADDON_COUNT,
ATTR_ADDONS,
ATTR_ARCH,
@@ -71,6 +82,7 @@ from .const import (
ATTR_PROTECTED,
ATTR_RECORDER,
ATTR_SLUG,
ATTR_SNAPSHOTS,
ATTR_STATE_COUNT,
ATTR_STATISTICS,
ATTR_SUPERVISOR,
@@ -80,8 +92,10 @@ from .const import (
ATTR_UUID,
ATTR_VERSION,
DOMAIN,
INTERVAL,
LOGGER,
PREFERENCE_SCHEMA,
SNAPSHOT_VERSION,
STORAGE_KEY,
STORAGE_VERSION,
)
@@ -194,13 +208,18 @@ def gen_uuid() -> str:
return uuid.uuid4().hex
RELEASE_CHANNEL = get_release_channel()
@dataclass
class AnalyticsData:
"""Analytics data."""
onboarded: bool
preferences: dict[str, bool]
uuid: str | None
uuid: str | None = None
submission_identifier: str | None = None
snapshot_submission_time: float | None = None
@classmethod
def from_dict(cls, data: dict[str, Any]) -> AnalyticsData:
@@ -209,6 +228,8 @@ class AnalyticsData:
data["onboarded"],
data["preferences"],
data["uuid"],
data.get("submission_identifier"),
data.get("snapshot_submission_time"),
)
@@ -219,8 +240,10 @@ class Analytics:
"""Initialize the Analytics class."""
self.hass: HomeAssistant = hass
self.session = async_get_clientsession(hass)
self._data = AnalyticsData(False, {}, None)
self._data = AnalyticsData(False, {})
self._store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
self._basic_scheduled: CALLBACK_TYPE | None = None
self._snapshot_scheduled: CALLBACK_TYPE | None = None
@property
def preferences(self) -> dict:
@@ -228,6 +251,7 @@ class Analytics:
preferences = self._data.preferences
return {
ATTR_BASE: preferences.get(ATTR_BASE, False),
ATTR_SNAPSHOTS: preferences.get(ATTR_SNAPSHOTS, False),
ATTR_DIAGNOSTICS: preferences.get(ATTR_DIAGNOSTICS, False),
ATTR_USAGE: preferences.get(ATTR_USAGE, False),
ATTR_STATISTICS: preferences.get(ATTR_STATISTICS, False),
@@ -244,9 +268,9 @@ class Analytics:
return self._data.uuid
@property
def endpoint(self) -> str:
def endpoint_basic(self) -> str:
"""Return the endpoint that will receive the payload."""
if HA_VERSION.endswith("0.dev0"):
if RELEASE_CHANNEL is ReleaseChannel.DEV:
# dev installations will contact the dev analytics environment
return ANALYTICS_ENDPOINT_URL_DEV
return ANALYTICS_ENDPOINT_URL
@@ -277,13 +301,17 @@ class Analytics:
):
self._data.preferences[ATTR_DIAGNOSTICS] = False
async def _save(self) -> None:
"""Save data."""
await self._store.async_save(dataclass_asdict(self._data))
async def save_preferences(self, preferences: dict) -> None:
"""Save preferences."""
preferences = PREFERENCE_SCHEMA(preferences)
self._data.preferences.update(preferences)
self._data.onboarded = True
await self._store.async_save(dataclass_asdict(self._data))
await self._save()
if self.supervisor:
await hassio.async_update_diagnostics(
@@ -292,17 +320,16 @@ class Analytics:
async def send_analytics(self, _: datetime | None = None) -> None:
"""Send analytics."""
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
return
hass = self.hass
supervisor_info = None
operating_system_info: dict[str, Any] = {}
if not self.onboarded or not self.preferences.get(ATTR_BASE, False):
LOGGER.debug("Nothing to submit")
return
if self._data.uuid is None:
self._data.uuid = gen_uuid()
await self._store.async_save(dataclass_asdict(self._data))
await self._save()
if self.supervisor:
supervisor_info = hassio.get_supervisor_info(hass)
@@ -436,7 +463,7 @@ class Analytics:
try:
async with timeout(30):
response = await self.session.post(self.endpoint, json=payload)
response = await self.session.post(self.endpoint_basic, json=payload)
if response.status == 200:
LOGGER.info(
(
@@ -449,7 +476,7 @@ class Analytics:
LOGGER.warning(
"Sending analytics failed with statuscode %s from %s",
response.status,
self.endpoint,
self.endpoint_basic,
)
except TimeoutError:
LOGGER.error("Timeout sending analytics to %s", ANALYTICS_ENDPOINT_URL)
@@ -489,6 +516,182 @@ class Analytics:
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
)
async def send_snapshot(self, _: datetime | None = None) -> None:
"""Send a snapshot."""
if not self.onboarded or not self.preferences.get(ATTR_SNAPSHOTS, False):
return
payload = await _async_snapshot_payload(self.hass)
headers = {
"Content-Type": "application/json",
"User-Agent": f"home-assistant/{HA_VERSION}",
}
if self._data.submission_identifier is not None:
headers["X-Device-Database-Submission-Identifier"] = (
self._data.submission_identifier
)
try:
async with timeout(30):
response = await self.session.post(
ANALYTICS_SNAPSHOT_ENDPOINT_URL, json=payload, headers=headers
)
if response.status == 200: # OK
response_data = await response.json()
new_identifier = response_data.get("submission_identifier")
if (
new_identifier is not None
and new_identifier != self._data.submission_identifier
):
self._data.submission_identifier = new_identifier
await self._save()
LOGGER.info(
"Submitted snapshot analytics to Home Assistant servers"
)
elif response.status == 400: # Bad Request
response_data = await response.json()
error_kind = response_data.get("kind", "unknown")
error_message = response_data.get("message", "Unknown error")
if error_kind == "invalid-submission-identifier":
# Clear the invalid identifier and retry on next cycle
LOGGER.warning(
"Invalid submission identifier to %s, clearing: %s",
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
error_message,
)
self._data.submission_identifier = None
await self._save()
else:
LOGGER.warning(
"Malformed snapshot analytics submission (%s) to %s: %s",
error_kind,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
error_message,
)
elif response.status == 503: # Service Unavailable
response_text = await response.text()
LOGGER.warning(
"Snapshot analytics service %s unavailable: %s",
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
response_text,
)
else:
LOGGER.warning(
"Unexpected status code %s when submitting snapshot analytics to %s",
response.status,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
)
except TimeoutError:
LOGGER.error(
"Timeout sending snapshot analytics to %s",
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
)
except aiohttp.ClientError as err:
LOGGER.error(
"Error sending snapshot analytics to %s: %r",
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
err,
)
async def async_schedule(self) -> None:
"""Schedule analytics."""
if not self.onboarded:
LOGGER.debug("Analytics not scheduled")
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
if self._snapshot_scheduled:
self._snapshot_scheduled()
self._snapshot_scheduled = None
return
if not self.preferences.get(ATTR_BASE, False):
LOGGER.debug("Basic analytics not scheduled")
if self._basic_scheduled is not None:
self._basic_scheduled()
self._basic_scheduled = None
elif self._basic_scheduled is None:
# Wait 15 min after started for basic analytics
self._basic_scheduled = async_call_later(
self.hass,
900,
HassJob(
self._async_schedule_basic,
name="basic analytics schedule",
cancel_on_shutdown=True,
),
)
if not self.preferences.get(ATTR_SNAPSHOTS, False) or RELEASE_CHANNEL not in (
ReleaseChannel.DEV,
ReleaseChannel.NIGHTLY,
):
LOGGER.debug("Snapshot analytics not scheduled")
if self._snapshot_scheduled:
self._snapshot_scheduled()
self._snapshot_scheduled = None
elif self._snapshot_scheduled is None:
snapshot_submission_time = self._data.snapshot_submission_time
if snapshot_submission_time is None:
# Randomize the submission time within the 24 hours
snapshot_submission_time = random.uniform(0, 86400)
self._data.snapshot_submission_time = snapshot_submission_time
await self._save()
LOGGER.debug(
"Initialized snapshot submission time to %s",
snapshot_submission_time,
)
# Calculate delay until next submission
current_time = time.time()
delay = (snapshot_submission_time - current_time) % 86400
self._snapshot_scheduled = async_call_later(
self.hass,
delay,
HassJob(
self._async_schedule_snapshots,
name="snapshot analytics schedule",
cancel_on_shutdown=True,
),
)
async def _async_schedule_basic(self, _: datetime | None = None) -> None:
"""Schedule basic analytics."""
await self.send_analytics()
# Send basic analytics every day
self._basic_scheduled = async_track_time_interval(
self.hass,
self.send_analytics,
INTERVAL,
name="basic analytics daily",
cancel_on_shutdown=True,
)
async def _async_schedule_snapshots(self, _: datetime | None = None) -> None:
"""Schedule snapshot analytics."""
await self.send_snapshot()
# Send snapshot analytics every day
self._snapshot_scheduled = async_track_time_interval(
self.hass,
self.send_snapshot,
INTERVAL,
name="snapshot analytics daily",
cancel_on_shutdown=True,
)
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
"""Extract domains from the YAML configuration."""
@@ -505,8 +708,8 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
"""Return detailed information about entities and devices."""
async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
"""Return detailed information about entities and devices for a snapshot."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
@@ -711,8 +914,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
entities_info.append(entity_info)
return integrations_info
async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices for a direct download."""
return {
"version": "home-assistant:1",
"version": f"home-assistant:{SNAPSHOT_VERSION}",
"home_assistant": HA_VERSION,
"integrations": integrations_info,
"integrations": await _async_snapshot_payload(hass),
}

View File

@@ -7,6 +7,8 @@ import voluptuous as vol
ANALYTICS_ENDPOINT_URL = "https://analytics-api.home-assistant.io/v1"
ANALYTICS_ENDPOINT_URL_DEV = "https://analytics-api-dev.home-assistant.io/v1"
SNAPSHOT_VERSION = "1"
ANALYTICS_SNAPSHOT_ENDPOINT_URL = f"https://device-database.eco-dev-aws.openhomefoundation.com/api/v1/snapshot/{SNAPSHOT_VERSION}"
DOMAIN = "analytics"
INTERVAL = timedelta(days=1)
STORAGE_KEY = "core.analytics"
@@ -38,6 +40,7 @@ ATTR_PREFERENCES = "preferences"
ATTR_PROTECTED = "protected"
ATTR_RECORDER = "recorder"
ATTR_SLUG = "slug"
ATTR_SNAPSHOTS = "snapshots"
ATTR_STATE_COUNT = "state_count"
ATTR_STATISTICS = "statistics"
ATTR_SUPERVISOR = "supervisor"
@@ -51,6 +54,7 @@ ATTR_VERSION = "version"
PREFERENCE_SCHEMA = vol.Schema(
{
vol.Optional(ATTR_BASE): bool,
vol.Optional(ATTR_SNAPSHOTS): bool,
vol.Optional(ATTR_DIAGNOSTICS): bool,
vol.Optional(ATTR_STATISTICS): bool,
vol.Optional(ATTR_USAGE): bool,

View File

@@ -7,3 +7,26 @@ CONNECTION_TIMEOUT: int = 10
# Field name of last self test retrieved from apcupsd.
LAST_S_TEST: Final = "laststest"
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
# repair issue translation keys.
DEPRECATED_SENSORS: Final = {
"apc": "apc_deprecated",
"end apc": "date_deprecated",
"date": "date_deprecated",
"apcmodel": "available_via_device_info",
"model": "available_via_device_info",
"firmware": "available_via_device_info",
"version": "available_via_device_info",
"upsname": "available_via_device_info",
"serialno": "available_via_device_info",
}
AVAILABLE_VIA_DEVICE_ATTR: Final = {
"apcmodel": "model",
"model": "model",
"firmware": "hw_version",
"version": "sw_version",
"upsname": "name",
"serialno": "serial_number",
}

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -22,9 +24,11 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.issue_registry as ir
from .const import LAST_S_TEST
from .const import AVAILABLE_VIA_DEVICE_ATTR, DEPRECATED_SENSORS, DOMAIN, LAST_S_TEST
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
from .entity import APCUPSdEntity
@@ -528,3 +532,62 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit
async def async_added_to_hass(self) -> None:
"""Handle when entity is added to Home Assistant.
If this is a deprecated sensor entity, create a repair issue to guide
the user to disable it.
"""
await super().async_added_to_hass()
if not self.enabled:
return
reason = DEPRECATED_SENSORS.get(self.entity_description.key)
if not reason:
return
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
if not automations and not scripts:
return
entity_registry = er.async_get(self.hass)
items = [
f"- [{entry.name or entry.original_name or entity_id}]"
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
for integration, entities in (
("automation", automations),
("script", scripts),
)
for entity_id in entities
if (entry := entity_registry.async_get(entity_id))
]
placeholders = {
"entity_name": str(self.name or self.entity_id),
"entity_id": self.entity_id,
"items": "\n".join(items),
}
if via_attr := AVAILABLE_VIA_DEVICE_ATTR.get(self.entity_description.key):
placeholders["available_via_device_attr"] = via_attr
if device_entry := self.device_entry:
placeholders["device_id"] = device_entry.id
ir.async_create_issue(
self.hass,
DOMAIN,
f"{reason}_{self.entity_id}",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key=reason,
translation_placeholders=placeholders,
)
async def async_will_remove_from_hass(self) -> None:
"""Handle when entity will be removed from Home Assistant."""
await super().async_will_remove_from_hass()
if issue_key := DEPRECATED_SENSORS.get(self.entity_description.key):
ir.async_delete_issue(self.hass, DOMAIN, f"{issue_key}_{self.entity_id}")

View File

@@ -241,5 +241,19 @@
"cannot_connect": {
"message": "Cannot connect to APC UPS Daemon."
}
},
"issues": {
"apc_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because it exposes internal details of the APC UPS Daemon response.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use supported APC UPS entities instead. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"available_via_device_info": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the same value is available from the device registry via `device_attr(\"{device_id}\", \"{available_via_device_attr}\")`.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to use the `device_attr` helper instead of this sensor. Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
},
"date_deprecated": {
"description": "The {entity_name} sensor (`{entity_id}`) is deprecated because the timestamp is already available from other APC UPS sensors via their last updated time.\n\nIt is still referenced in the following automations or scripts:\n{items}\n\nUpdate those automations or scripts to reference any entity's `last_updated` attribute instead (for example, `states.binary_sensor.apcups_online_status.last_updated`). Reload the APC UPS Daemon integration afterwards to resolve this issue.",
"title": "{entity_name} sensor is deprecated"
}
}
}

View File

@@ -24,7 +24,7 @@ class BrotherPrinterEntity(CoordinatorEntity[BrotherDataUpdateCoordinator]):
connections={(CONNECTION_NETWORK_MAC, coordinator.brother.mac)},
serial_number=coordinator.brother.serial,
manufacturer="Brother",
model=coordinator.brother.model,
model_id=coordinator.brother.model,
name=coordinator.brother.model,
sw_version=coordinator.brother.firmware,
)

View File

@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -345,12 +345,10 @@ class BrotherPrinterSensor(BrotherPrinterEntity, SensorEntity):
"""Initialize."""
super().__init__(coordinator)
self._attr_native_value = description.value(coordinator.data)
self._attr_unique_id = f"{coordinator.brother.serial.lower()}_{description.key}"
self.entity_description = description
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = self.entity_description.value(self.coordinator.data)
self.async_write_ha_state()
@property
def native_value(self) -> StateType | datetime:
"""Return the native value of the sensor."""
return self.entity_description.value(self.coordinator.data)

View File

@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, websocket_create_area)
websocket_api.async_register_command(hass, websocket_delete_area)
websocket_api.async_register_command(hass, websocket_update_area)
websocket_api.async_register_command(hass, websocket_reorder_areas)
return True
@@ -145,3 +146,27 @@ def websocket_update_area(
connection.send_error(msg["id"], "invalid_info", str(err))
else:
connection.send_result(msg["id"], entry.json_fragment)
@websocket_api.websocket_command(
{
vol.Required("type"): "config/area_registry/reorder",
vol.Required("area_ids"): [str],
}
)
@websocket_api.require_admin
@callback
def websocket_reorder_areas(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Handle reorder areas websocket command."""
registry = ar.async_get(hass)
try:
registry.async_reorder(msg["area_ids"])
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -18,6 +18,7 @@ def async_setup(hass: HomeAssistant) -> bool:
websocket_api.async_register_command(hass, websocket_create_floor)
websocket_api.async_register_command(hass, websocket_delete_floor)
websocket_api.async_register_command(hass, websocket_update_floor)
websocket_api.async_register_command(hass, websocket_reorder_floors)
return True
@@ -127,6 +128,28 @@ def websocket_update_floor(
connection.send_result(msg["id"], _entry_dict(entry))
@websocket_api.websocket_command(
{
vol.Required("type"): "config/floor_registry/reorder",
vol.Required("floor_ids"): [str],
}
)
@websocket_api.require_admin
@callback
def websocket_reorder_floors(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle reorder floors websocket command."""
registry = fr.async_get(hass)
try:
registry.async_reorder(msg["floor_ids"])
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
else:
connection.send_result(msg["id"])
@callback
def _entry_dict(entry: FloorEntry) -> dict[str, Any]:
"""Convert entry to API format."""

View File

@@ -11,11 +11,14 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
@@ -23,6 +26,9 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_set_user_data)
websocket_api.async_register_command(hass, websocket_get_user_data)
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
websocket_api.async_register_command(hass, websocket_set_system_data)
websocket_api.async_register_command(hass, websocket_get_system_data)
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
@@ -83,6 +89,52 @@ class _UserStore(Store[dict[str, Any]]):
)
@singleton.singleton(DATA_SYSTEM_STORAGE, async_=True)
async def async_system_store(hass: HomeAssistant) -> SystemStore:
"""Access the system store."""
store = SystemStore(hass)
await store.async_load()
return store
class SystemStore:
"""System store for frontend data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the system store."""
self._store: Store[dict[str, Any]] = Store(
hass,
STORAGE_VERSION_SYSTEM_DATA,
"frontend.system_data",
)
self.data: dict[str, Any] = {}
self.subscriptions: dict[str, list[Callable[[], None]]] = {}
async def async_load(self) -> None:
"""Load the data from the store."""
self.data = await self._store.async_load() or {}
async def async_set_item(self, key: str, value: Any) -> None:
"""Set an item and save the store."""
self.data[key] = value
self._store.async_delay_save(lambda: self.data, 1.0)
for cb in self.subscriptions.get(key, []):
cb()
@callback
def async_subscribe(
self, key: str, on_update_callback: Callable[[], None]
) -> Callable[[], None]:
"""Subscribe to store updates."""
self.subscriptions.setdefault(key, []).append(on_update_callback)
def unsubscribe() -> None:
"""Unsubscribe from the store."""
self.subscriptions[key].remove(on_update_callback)
return unsubscribe
def with_user_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
@@ -107,6 +159,28 @@ def with_user_store(
return with_user_store_func
def with_system_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate function to provide system store."""
@wraps(orig_func)
async def with_system_store_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide system store to function."""
store = await async_system_store(hass)
await orig_func(hass, connection, msg, store)
return with_system_store_func
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_user_data",
@@ -169,3 +243,65 @@ async def websocket_subscribe_user_data(
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_system_data",
vol.Required("key"): str,
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
@with_system_store
async def websocket_set_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle set system data command."""
await store.async_set_item(msg["key"], msg["value"])
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{vol.Required("type"): "frontend/get_system_data", vol.Required("key"): str}
)
@websocket_api.async_response
@with_system_store
async def websocket_get_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle get system data command."""
connection.send_result(msg["id"], {"value": store.data.get(msg["key"])})
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/subscribe_system_data",
vol.Required("key"): str,
}
)
@websocket_api.async_response
@with_system_store
async def websocket_subscribe_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle subscribe to system data command."""
key: str = msg["key"]
def on_data_update() -> None:
"""Handle system data update."""
connection.send_event(msg["id"], {"value": store.data.get(key)})
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.1.0",
"universal-silabs-flasher==0.1.2",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.0"]
"requirements": ["aioautomower==2.7.1"]
}

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
from asyncio import Task
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -44,7 +45,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
_default_update_interval = SCAN_INTERVAL
config_entry: LaMarzoccoConfigEntry
websocket_terminated = True
_websocket_task: Task | None = None
def __init__(
self,
@@ -64,6 +65,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
self.device = device
self.cloud_client = cloud_client
@property
def websocket_terminated(self) -> bool:
"""Return True if the websocket task is terminated or not running."""
if self._websocket_task is None:
return True
return self._websocket_task.done()
async def _async_update_data(self) -> None:
"""Do the data update."""
try:
@@ -95,13 +103,14 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
# ensure token stays valid; does nothing if token is still valid
await self.cloud_client.async_get_access_token()
if self.device.websocket.connected:
# Only skip websocket reconnection if it's currently connected and the task is still running
if self.device.websocket.connected and not self.websocket_terminated:
return
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
self.config_entry.async_create_background_task(
self._websocket_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self.connect_websocket(),
name="lm_websocket_task",
@@ -120,7 +129,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
_LOGGER.debug("Init WebSocket in background task")
self.websocket_terminated = False
self.async_update_listeners()
await self.device.connect_dashboard_websocket(
@@ -129,7 +137,6 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
disconnect_callback=self.async_update_listeners,
)
self.websocket_terminated = True
self.async_update_listeners()

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from functools import partial
import logging
from typing import cast
import pypck
from pypck.connection import (
@@ -48,7 +49,6 @@ from .const import (
)
from .helpers import (
AddressType,
InputType,
LcnConfigEntry,
LcnRuntimeData,
async_update_config_entry,
@@ -285,7 +285,7 @@ def _async_fire_access_control_event(
hass: HomeAssistant,
device: dr.DeviceEntry | None,
address: AddressType,
inp: InputType,
inp: pypck.inputs.ModStatusAccessControl,
) -> None:
"""Fire access control event (transponder, transmitter, fingerprint, codelock)."""
event_data = {
@@ -299,7 +299,11 @@ def _async_fire_access_control_event(
if inp.periphery == pypck.lcn_defs.AccessControlPeriphery.TRANSMITTER:
event_data.update(
{"level": inp.level, "key": inp.key, "action": inp.action.value}
{
"level": inp.level,
"key": inp.key,
"action": cast(pypck.lcn_defs.KeyAction, inp.action).value,
}
)
event_name = f"lcn_{inp.periphery.value.lower()}"
@@ -310,7 +314,7 @@ def _async_fire_send_keys_event(
hass: HomeAssistant,
device: dr.DeviceEntry | None,
address: AddressType,
inp: InputType,
inp: pypck.inputs.ModSendKeysHost,
) -> None:
"""Fire send_keys event."""
for table, action in enumerate(inp.actions):

View File

@@ -100,8 +100,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP]
self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP]
self._current_temperature = None
self._target_temperature = None
self._is_on = True
self._attr_hvac_modes = [HVACMode.HEAT]
@@ -121,16 +119,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._target_temperature
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode.
@@ -166,7 +154,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
):
return
self._is_on = False
self._target_temperature = None
self._attr_target_temperature = None
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -178,7 +166,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
self.setpoint, temperature, self.unit
):
return
self._target_temperature = temperature
self._attr_target_temperature = temperature
self.async_write_ha_state()
async def async_update(self) -> None:
@@ -198,10 +186,14 @@ class LcnClimate(LcnEntity, ClimateEntity):
return
if input_obj.get_var() == self.variable:
self._current_temperature = input_obj.get_value().to_var_unit(self.unit)
self._attr_current_temperature = float(
input_obj.get_value().to_var_unit(self.unit)
)
elif input_obj.get_var() == self.setpoint:
self._is_on = not input_obj.get_value().is_locked_regulator()
if self._is_on:
self._target_temperature = input_obj.get_value().to_var_unit(self.unit)
self._attr_target_temperature = float(
input_obj.get_value().to_var_unit(self.unit)
)
self.async_write_ha_state()

View File

@@ -120,7 +120,7 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors={CONF_BASE: error},
)
data: dict = {
data: dict[str, Any] = {
**user_input,
CONF_DEVICES: [],
CONF_ENTITIES: [],

View File

@@ -1,7 +1,7 @@
"""Support for LCN covers."""
import asyncio
from collections.abc import Iterable
from collections.abc import Coroutine, Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -81,6 +81,8 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
_attr_is_opening = False
_attr_assumed_state = True
reverse_time: pypck.lcn_defs.MotorReverseTime | None
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
"""Initialize the LCN cover."""
super().__init__(config, config_entry)
@@ -255,7 +257,15 @@ class LcnRelayCover(LcnEntity, CoverEntity):
async def async_update(self) -> None:
"""Update the state of the entity."""
coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
coros: list[
Coroutine[
Any,
Any,
pypck.inputs.ModStatusRelays
| pypck.inputs.ModStatusMotorPositionBS4
| None,
]
] = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4:
coros.append(
self.device_connection.request_status_motor_position(
@@ -283,7 +293,7 @@ class LcnRelayCover(LcnEntity, CoverEntity):
)
and input_obj.motor == self.motor.value
):
self._attr_current_cover_position = input_obj.position
self._attr_current_cover_position = int(input_obj.position)
if self._attr_current_cover_position in [0, 100]:
self._attr_is_opening = False
self._attr_is_closing = False

View File

@@ -35,7 +35,7 @@ class LcnEntity(Entity):
self.config = config
self.config_entry = config_entry
self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None
self._unregister_for_inputs: Callable[[], None] | None = None
self._name: str = config[CONF_NAME]
self._attr_device_info = DeviceInfo(
identifiers={

View File

@@ -61,7 +61,7 @@ type LcnConfigEntry = ConfigEntry[LcnRuntimeData]
type AddressType = tuple[int, int, bool]
type InputType = type[pypck.inputs.Input]
type InputType = pypck.inputs.Input
# Regex for address validation
PATTERN_ADDRESS = re.compile(
@@ -269,10 +269,10 @@ async def async_update_device_config(
if device_config[CONF_NAME] != "":
return
device_name = ""
device_name: str | None = None
if not is_group:
device_name = await device_connection.request_name()
if is_group or device_name == "":
if is_group or device_name is None:
module_type = "Group" if is_group else "Module"
device_name = (
f"{module_type} "

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.9.5", "lcn-frontend==0.2.7"]
}

View File

@@ -74,4 +74,4 @@ rules:
status: exempt
comment: |
Integration is not making any HTTP requests.
strict-typing: todo
strict-typing: done

View File

@@ -156,6 +156,8 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
class LcnLedLogicSensor(LcnEntity, SensorEntity):
"""Representation of a LCN sensor for leds and logicops."""
source: pypck.lcn_defs.LedPort | pypck.lcn_defs.LogicOpPort
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
"""Initialize the LCN sensor."""
super().__init__(config, config_entry)

View File

@@ -104,7 +104,9 @@ def get_config_entry(
@wraps(func)
async def get_entry(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get config_entry."""
if not (config_entry := hass.config_entries.async_get_entry(msg["entry_id"])):
@@ -124,7 +126,7 @@ def get_config_entry(
async def websocket_get_device_configs(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Get device configs."""
@@ -144,7 +146,7 @@ async def websocket_get_device_configs(
async def websocket_get_entity_configs(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Get entities configs."""
@@ -175,7 +177,7 @@ async def websocket_get_entity_configs(
async def websocket_scan_devices(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Scan for new devices."""
@@ -207,7 +209,7 @@ async def websocket_scan_devices(
async def websocket_add_device(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Add a device."""
@@ -253,7 +255,7 @@ async def websocket_add_device(
async def websocket_delete_device(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Delete a device."""
@@ -315,7 +317,7 @@ async def websocket_delete_device(
async def websocket_add_entity(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Add an entity."""
@@ -381,7 +383,7 @@ async def websocket_add_entity(
async def websocket_delete_entity(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Delete an entity."""
@@ -451,7 +453,7 @@ async def async_create_or_update_device_in_config_entry(
def get_entity_entry(
hass: HomeAssistant, entity_config: dict, config_entry: LcnConfigEntry
hass: HomeAssistant, entity_config: dict[str, Any], config_entry: LcnConfigEntry
) -> er.RegistryEntry | None:
"""Get entity RegistryEntry from entity_config."""
entity_registry = er.async_get(hass)

View File

@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
"requirements": ["pylutron-caseta==0.25.0"],
"requirements": ["pylutron-caseta==0.26.0"],
"zeroconf": [
{
"properties": {

View File

@@ -183,13 +183,6 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
@@ -1495,47 +1488,4 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
state_class=None,
# convert to set first to remove the duplicate unknown value
options=[x for x in SETPOINT_CHANGE_SOURCE_MAP.values() if x is not None],
device_to_ha=lambda x: SETPOINT_CHANGE_SOURCE_MAP[x],
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSourceTimestamp",
translation_key="setpoint_change_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
state_class=None,
device_to_ha=(lambda x: dt_util.utc_from_timestamp(x) if x > 0 else None),
),
entity_class=MatterSensor,
required_attributes=(
clusters.Thermostat.Attributes.SetpointChangeSourceTimestamp,
),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="ThermostatSetpointChangeAmount",
translation_key="setpoint_change_amount",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
device_class=SensorDeviceClass.TEMPERATURE,
device_to_ha=lambda x: x / TEMPERATURE_SCALING_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeAmount,),
device_type=(device_types.Thermostat,),
),
]

View File

@@ -223,9 +223,6 @@
"pump_setpoint": {
"name": "Setpoint"
},
"setpoint_change_source_timestamp": {
"name": "Last change"
},
"temperature_offset": {
"name": "Temperature offset"
},
@@ -521,20 +518,6 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_amount": {
"name": "Last change amount"
},
"setpoint_change_source": {
"name": "Last change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"setpoint_change_timestamp": {
"name": "Last change"
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -11,7 +11,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
from .coordinator import (
OhmeAdvancedSettingsCoordinator,
OhmeChargeSessionCoordinator,
OhmeConfigEntry,
OhmeDeviceInfoCoordinator,
@@ -56,7 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
coordinators = (
OhmeChargeSessionCoordinator(hass, entry, client),
OhmeAdvancedSettingsCoordinator(hass, entry, client),
OhmeDeviceInfoCoordinator(hass, entry, client),
)

View File

@@ -10,7 +10,7 @@ import logging
from ohme import ApiException, OhmeApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -23,7 +23,6 @@ class OhmeRuntimeData:
"""Dataclass to hold ohme coordinators."""
charge_session_coordinator: OhmeChargeSessionCoordinator
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
device_info_coordinator: OhmeDeviceInfoCoordinator
@@ -78,31 +77,6 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
await self.client.async_get_charge_session()
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull settings and charger state from the API."""
coordinator_name = "Advanced Settings"
def __init__(
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
) -> None:
"""Initialise coordinator."""
super().__init__(hass, config_entry, client)
@callback
def _dummy_listener() -> None:
pass
# This coordinator is used by the API library to determine whether the
# charger is online and available. It is therefore required even if no
# entities are using it.
self.async_add_listener(_dummy_listener)
async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull device info and charger settings from the API."""

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.5.2"]
"requirements": ["ohme==1.6.0"]
}

View File

@@ -37,7 +37,7 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
value_fn: Callable[[OhmeApiClient], str | int | float | None]
SENSOR_CHARGE_SESSION = [
SENSORS = [
OhmeSensorDescription(
key="status",
translation_key="status",
@@ -91,18 +91,6 @@ SENSOR_CHARGE_SESSION = [
),
]
SENSOR_ADVANCED_SETTINGS = [
OhmeSensorDescription(
key="ct_current",
translation_key="ct_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.ct_amps,
is_supported_fn=lambda client: client.ct_connected,
entity_registry_enabled_default=False,
),
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -110,16 +98,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinators = config_entry.runtime_data
coordinator_map = [
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
]
coordinator = config_entry.runtime_data.charge_session_coordinator
async_add_entities(
OhmeSensor(coordinator, description)
for entities, coordinator in coordinator_map
for description in entities
for description in SENSORS
if description.is_supported_fn(coordinator.client)
)

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.16"]
"requirements": ["onedrive-personal-sdk==0.0.17"]
}

View File

@@ -14,7 +14,7 @@
"iot_class": "local_push",
"loggers": ["aioshelly"],
"quality_scale": "silver",
"requirements": ["aioshelly==13.17.0"],
"requirements": ["aioshelly==13.19.0"],
"zeroconf": [
{
"name": "shelly*",

View File

@@ -18,7 +18,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER
from .types import DaliCenterConfigEntry, DaliCenterData
@@ -47,12 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
"You can try to delete the gateway and add it again"
) from exc
def on_online_status(dev_id: str, available: bool) -> None:
signal = f"{DOMAIN}_update_available_{dev_id}"
hass.add_job(async_dispatcher_send, hass, signal, available)
gateway.on_online_status = on_online_status
try:
devices = await gateway.discover_devices()
except DaliGatewayError as exc:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from PySrDaliGateway import Device
from PySrDaliGateway import CallbackEventType, Device
from PySrDaliGateway.helper import is_light_device
from PySrDaliGateway.types import LightStatus
@@ -19,10 +19,6 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
@@ -40,15 +36,8 @@ async def async_setup_entry(
) -> None:
"""Set up Sunricher DALI light entities from config entry."""
runtime_data = entry.runtime_data
gateway = runtime_data.gateway
devices = runtime_data.devices
def _on_light_status(dev_id: str, status: LightStatus) -> None:
signal = f"{DOMAIN}_update_{dev_id}"
hass.add_job(async_dispatcher_send, hass, signal, status)
gateway.on_light_status = _on_light_status
async_add_entities(
DaliCenterLight(device)
for device in devices
@@ -123,14 +112,16 @@ class DaliCenterLight(LightEntity):
async def async_added_to_hass(self) -> None:
"""Handle entity addition to Home Assistant."""
signal = f"{DOMAIN}_update_{self._attr_unique_id}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self._handle_device_update)
self._light.register_listener(
CallbackEventType.LIGHT_STATUS, self._handle_device_update
)
)
signal = f"{DOMAIN}_update_available_{self._attr_unique_id}"
self.async_on_remove(
async_dispatcher_connect(self.hass, signal, self._handle_availability)
self._light.register_listener(
CallbackEventType.ONLINE_STATUS, self._handle_availability
)
)
# read_status() only queues a request on the gateway and relies on the
@@ -187,4 +178,4 @@ class DaliCenterLight(LightEntity):
):
self._attr_rgbw_color = status["rgbw_color"]
self.async_write_ha_state()
self.schedule_update_ha_state()

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["PySrDaliGateway==0.13.1"]
"requirements": ["PySrDaliGateway==0.16.2"]
}

View File

@@ -35,7 +35,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
test-coverage: done
# Gold
devices: done

View File

@@ -851,11 +851,16 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
key=DPCode.EXCRETION_TIME_DAY,
translation_key="excretion_time_day",
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
),
TuyaSensorEntityDescription(
key=DPCode.EXCRETION_TIMES_DAY,
translation_key="excretion_times_day",
),
TuyaSensorEntityDescription(
key=DPCode.STATUS,
translation_key="cat_litter_box_status",
),
),
DeviceCategory.MZJ: (
TuyaSensorEntityDescription(

View File

@@ -617,6 +617,17 @@
"carbon_monoxide": {
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
},
"cat_litter_box_status": {
"name": "[%key:component::tuya::entity::sensor::status::name%]",
"state": {
"clean": "Cleaning",
"empty": "Emptying",
"level": "Leveling",
"sleep": "Sleeping",
"standby": "[%key:common::state::standby%]",
"uv": "Sanitizing"
}
},
"cat_weight": {
"name": "Cat weight"
},
@@ -645,7 +656,7 @@
"name": "Duster cloth lifetime"
},
"excretion_time_day": {
"name": "Excretion time (day)"
"name": "Excretion duration"
},
"excretion_times_day": {
"name": "Excretion times (day)"

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
"requirements": ["aiounifi==87"],
"requirements": ["aiounifi==88"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -104,6 +104,15 @@ def async_client_uptime_value_fn(hub: UnifiHub, client: Client) -> datetime:
return dt_util.utc_from_timestamp(float(client.uptime))
@callback
def async_wired_client_allowed_fn(hub: UnifiHub, obj_id: str) -> bool:
"""Check if client is wired and allowed."""
client = hub.api.clients[obj_id]
if not client.is_wired or client.wired_rate_mbps <= 0:
return False
return True
@callback
def async_wlan_client_value_fn(hub: UnifiHub, wlan: Wlan) -> int:
"""Calculate the amount of clients connected to a wlan."""
@@ -407,6 +416,23 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = (
unique_id_fn=lambda hub, obj_id: f"tx-{obj_id}",
value_fn=async_client_tx_value_fn,
),
UnifiSensorEntityDescription[Clients, Client](
key="Wired client speed",
translation_key="wired_client_link_speed",
device_class=SensorDeviceClass.DATA_RATE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
entity_registry_enabled_default=False,
allowed_fn=async_wired_client_allowed_fn,
api_handler_fn=lambda api: api.clients,
device_info_fn=async_client_device_info_fn,
is_connected_fn=async_client_is_connected_fn,
name_fn=lambda _: "Link speed",
object_fn=lambda api, obj_id: api.clients[obj_id],
unique_id_fn=lambda hub, obj_id: f"wired_speed-{obj_id}",
value_fn=lambda hub, client: client.wired_rate_mbps,
),
UnifiSensorEntityDescription[Ports, Port](
key="PoE port power sensor",
device_class=SensorDeviceClass.POWER,

View File

@@ -55,6 +55,9 @@
"provisioning": "Provisioning",
"upgrading": "Upgrading"
}
},
"wired_client_link_speed": {
"name": "Link speed"
}
}
},

View File

@@ -0,0 +1,54 @@
"""The Victron Bluetooth Low Energy integration."""
from __future__ import annotations
import logging
from victron_ble_ha_parser import VictronBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_rediscover_address,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Victron BLE device from a config entry."""
address = entry.unique_id
assert address is not None
key = entry.data[CONF_ACCESS_TOKEN]
data = VictronBluetoothDeviceData(key)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR]
)
if unload_ok:
async_rediscover_address(hass, entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,123 @@
"""Config flow for Victron Bluetooth Low Energy integration."""
from __future__ import annotations
import logging
from typing import Any
from victron_ble_ha_parser import VictronBluetoothDeviceData
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from .const import DOMAIN, VICTRON_IDENTIFIER
_LOGGER = logging.getLogger(__name__)
STEP_ACCESS_TOKEN_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACCESS_TOKEN): str,
}
)
class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Victron Bluetooth Low Energy."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_device: str | None = None
self._discovered_devices: dict[str, str] = {}
self._discovered_devices_info: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("async_step_bluetooth: %s", discovery_info.address)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = VictronBluetoothDeviceData()
if not device.supported(discovery_info):
_LOGGER.debug("device %s not supported", discovery_info.address)
return self.async_abort(reason="not_supported")
self._discovered_device = discovery_info.address
self._discovered_devices_info[discovery_info.address] = discovery_info
self._discovered_devices[discovery_info.address] = discovery_info.name
self.context["title_placeholders"] = {"title": discovery_info.name}
return await self.async_step_access_token()
async def async_step_access_token(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle advertisement key input."""
# should only be called if there are discovered devices
assert self._discovered_device is not None
discovery_info = self._discovered_devices_info[self._discovered_device]
title = discovery_info.name
if user_input is not None:
# see if we can create a device with the access token
device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN])
if device.validate_advertisement_key(
discovery_info.manufacturer_data[VICTRON_IDENTIFIER]
):
return self.async_create_entry(
title=title,
data=user_input,
)
return self.async_abort(reason="invalid_access_token")
return self.async_show_form(
step_id="access_token",
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
description_placeholders={"title": title},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle select a device to set up."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
self._discovered_device = address
title = self._discovered_devices_info[address].name
return self.async_show_form(
step_id="access_token",
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
description_placeholders={"title": title},
)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = VictronBluetoothDeviceData()
if device.supported(discovery_info):
self._discovered_devices_info[address] = discovery_info
self._discovered_devices[address] = discovery_info.name
if len(self._discovered_devices) < 1:
return self.async_abort(reason="no_devices_found")
_LOGGER.debug("Discovered %s devices", len(self._discovered_devices))
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)

View File

@@ -0,0 +1,4 @@
"""Constants for the Victron Bluetooth Low Energy integration."""
DOMAIN = "victron_ble"
VICTRON_IDENTIFIER = 0x02E1

View File

@@ -0,0 +1,19 @@
{
"domain": "victron_ble",
"name": "Victron BLE",
"bluetooth": [
{
"connectable": false,
"manufacturer_data_start": [16],
"manufacturer_id": 737
}
],
"codeowners": ["@rajlaud"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/victron_ble",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["victron-ble-ha-parser==0.4.9"]
}

View File

@@ -0,0 +1,85 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: exempt
comment: |
There is nothing to test, the integration just passively receives BLE advertisements.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: done
reauthentication-flow:
status: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not use IP addresses. Bluetooth MAC addresses do not change.
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device per instance, and each device needs a user-supplied encryption key to set up.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,474 @@
"""Sensor platform for Victron BLE."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from sensor_state_data import DeviceKey
from victron_ble_ha_parser import Keys, Units
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
LOGGER = logging.getLogger(__name__)
AC_IN_OPTIONS = [
"ac_in_1",
"ac_in_2",
"not_connected",
]
ALARM_OPTIONS = [
"low_voltage",
"high_voltage",
"low_soc",
"low_starter_voltage",
"high_starter_voltage",
"low_temperature",
"high_temperature",
"mid_voltage",
"overload",
"dc_ripple",
"low_v_ac_out",
"high_v_ac_out",
"short_circuit",
"bms_lockout",
]
CHARGER_ERROR_OPTIONS = [
"no_error",
"temperature_battery_high",
"voltage_high",
"remote_temperature_auto_reset",
"remote_temperature_not_auto_reset",
"remote_battery",
"high_ripple",
"temperature_battery_low",
"temperature_charger",
"over_current",
"bulk_time",
"current_sensor",
"internal_temperature",
"fan",
"overheated",
"short_circuit",
"converter_issue",
"over_charge",
"input_voltage",
"input_current",
"input_power",
"input_shutdown_voltage",
"input_shutdown_current",
"input_shutdown_failure",
"inverter_shutdown_pv_isolation",
"inverter_shutdown_ground_fault",
"inverter_overload",
"inverter_temperature",
"inverter_peak_current",
"inverter_output_voltage",
"inverter_self_test",
"inverter_ac",
"communication",
"synchronisation",
"bms",
"network",
"pv_input_shutdown",
"cpu_temperature",
"calibration_lost",
"firmware",
"settings",
"tester_fail",
"internal_dc_voltage",
"self_test",
"internal_supply",
]
def error_to_state(value: float | str | None) -> str | None:
"""Convert error code to state string."""
value_map: dict[Any, str] = {
"internal_supply_a": "internal_supply",
"internal_supply_b": "internal_supply",
"internal_supply_c": "internal_supply",
"internal_supply_d": "internal_supply",
"inverter_shutdown_41": "inverter_shutdown_pv_isolation",
"inverter_shutdown_42": "inverter_shutdown_pv_isolation",
"inverter_shutdown_43": "inverter_shutdown_ground_fault",
"internal_temperature_a": "internal_temperature",
"internal_temperature_b": "internal_temperature",
"inverter_output_voltage_a": "inverter_output_voltage",
"inverter_output_voltage_b": "inverter_output_voltage",
"internal_dc_voltage_a": "internal_dc_voltage",
"internal_dc_voltage_b": "internal_dc_voltage",
"remote_temperature_a": "remote_temperature_auto_reset",
"remote_temperature_b": "remote_temperature_auto_reset",
"remote_temperature_c": "remote_temperature_not_auto_reset",
"remote_battery_a": "remote_battery",
"remote_battery_b": "remote_battery",
"remote_battery_c": "remote_battery",
"pv_input_shutdown_80": "pv_input_shutdown",
"pv_input_shutdown_81": "pv_input_shutdown",
"pv_input_shutdown_82": "pv_input_shutdown",
"pv_input_shutdown_83": "pv_input_shutdown",
"pv_input_shutdown_84": "pv_input_shutdown",
"pv_input_shutdown_85": "pv_input_shutdown",
"pv_input_shutdown_86": "pv_input_shutdown",
"pv_input_shutdown_87": "pv_input_shutdown",
"inverter_self_test_a": "inverter_self_test",
"inverter_self_test_b": "inverter_self_test",
"inverter_self_test_c": "inverter_self_test",
"network_a": "network",
"network_b": "network",
"network_c": "network",
"network_d": "network",
}
return value_map.get(value)
DEVICE_STATE_OPTIONS = [
"off",
"low_power",
"fault",
"bulk",
"absorption",
"float",
"storage",
"equalize_manual",
"inverting",
"power_supply",
"starting_up",
"repeated_absorption",
"recondition",
"battery_safe",
"active",
"external_control",
"not_available",
]
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class VictronBLESensorEntityDescription(SensorEntityDescription):
"""Describes Victron BLE sensor entity."""
value_fn: Callable[[float | int | str | None], float | int | str | None] = (
lambda x: x
)
SENSOR_DESCRIPTIONS = {
Keys.AC_IN_POWER: VictronBLESensorEntityDescription(
key=Keys.AC_IN_POWER,
translation_key=Keys.AC_IN_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.AC_IN_STATE: VictronBLESensorEntityDescription(
key=Keys.AC_IN_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="ac_in_state",
options=AC_IN_OPTIONS,
),
Keys.AC_OUT_POWER: VictronBLESensorEntityDescription(
key=Keys.AC_OUT_POWER,
translation_key=Keys.AC_OUT_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.AC_OUT_STATE: VictronBLESensorEntityDescription(
key=Keys.AC_OUT_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="device_state",
options=DEVICE_STATE_OPTIONS,
),
Keys.ALARM: VictronBLESensorEntityDescription(
key=Keys.ALARM,
device_class=SensorDeviceClass.ENUM,
translation_key="alarm",
options=ALARM_OPTIONS,
),
Keys.BALANCER_STATUS: VictronBLESensorEntityDescription(
key=Keys.BALANCER_STATUS,
device_class=SensorDeviceClass.ENUM,
translation_key="balancer_status",
options=["balanced", "balancing", "imbalance"],
),
Keys.BATTERY_CURRENT: VictronBLESensorEntityDescription(
key=Keys.BATTERY_CURRENT,
translation_key=Keys.BATTERY_CURRENT,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.BATTERY_TEMPERATURE: VictronBLESensorEntityDescription(
key=Keys.BATTERY_TEMPERATURE,
translation_key=Keys.BATTERY_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.BATTERY_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.BATTERY_VOLTAGE,
translation_key=Keys.BATTERY_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.CHARGER_ERROR: VictronBLESensorEntityDescription(
key=Keys.CHARGER_ERROR,
device_class=SensorDeviceClass.ENUM,
translation_key="charger_error",
options=CHARGER_ERROR_OPTIONS,
value_fn=error_to_state,
),
Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription(
key=Keys.CONSUMED_AMPERE_HOURS,
translation_key=Keys.CONSUMED_AMPERE_HOURS,
native_unit_of_measurement=Units.ELECTRIC_CURRENT_FLOW_AMPERE_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.CURRENT: VictronBLESensorEntityDescription(
key=Keys.CURRENT,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.DEVICE_STATE: VictronBLESensorEntityDescription(
key=Keys.DEVICE_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="device_state",
options=DEVICE_STATE_OPTIONS,
),
Keys.ERROR_CODE: VictronBLESensorEntityDescription(
key=Keys.ERROR_CODE,
device_class=SensorDeviceClass.ENUM,
translation_key="charger_error",
options=CHARGER_ERROR_OPTIONS,
),
Keys.EXTERNAL_DEVICE_LOAD: VictronBLESensorEntityDescription(
key=Keys.EXTERNAL_DEVICE_LOAD,
translation_key=Keys.EXTERNAL_DEVICE_LOAD,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.INPUT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.INPUT_VOLTAGE,
translation_key=Keys.INPUT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.METER_TYPE: VictronBLESensorEntityDescription(
key=Keys.METER_TYPE,
device_class=SensorDeviceClass.ENUM,
translation_key="meter_type",
options=[
"solar_charger",
"wind_charger",
"shaft_generator",
"alternator",
"fuel_cell",
"water_generator",
"dc_dc_charger",
"ac_charger",
"generic_source",
"generic_load",
"electric_drive",
"fridge",
"water_pump",
"bilge_pump",
"dc_system",
"inverter",
"water_heater",
],
),
Keys.MIDPOINT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.MIDPOINT_VOLTAGE,
translation_key=Keys.MIDPOINT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.OFF_REASON: VictronBLESensorEntityDescription(
key=Keys.OFF_REASON,
device_class=SensorDeviceClass.ENUM,
translation_key="off_reason",
options=[
"no_reason",
"no_input_power",
"switched_off_switch",
"switched_off_register",
"remote_input",
"protection_active",
"pay_as_you_go_out_of_credit",
"bms",
"engine_shutdown",
"analysing_input_voltage",
],
),
Keys.OUTPUT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_VOLTAGE,
translation_key=Keys.OUTPUT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.REMAINING_MINUTES: VictronBLESensorEntityDescription(
key=Keys.REMAINING_MINUTES,
translation_key=Keys.REMAINING_MINUTES,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
),
SensorDeviceClass.SIGNAL_STRENGTH: VictronBLESensorEntityDescription(
key=SensorDeviceClass.SIGNAL_STRENGTH.value,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.SOLAR_POWER: VictronBLESensorEntityDescription(
key=Keys.SOLAR_POWER,
translation_key=Keys.SOLAR_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.STARTER_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.STARTER_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.STATE_OF_CHARGE: VictronBLESensorEntityDescription(
key=Keys.STATE_OF_CHARGE,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.TEMPERATURE: VictronBLESensorEntityDescription(
key=Keys.TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.WARNING: VictronBLESensorEntityDescription(
key=Keys.WARNING,
device_class=SensorDeviceClass.ENUM,
translation_key="alarm",
options=ALARM_OPTIONS,
),
Keys.YIELD_TODAY: VictronBLESensorEntityDescription(
key=Keys.YIELD_TODAY,
translation_key=Keys.YIELD_TODAY,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
}
for i in range(1, 8):
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
key=cell_key,
translation_key="cell_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
)
def _device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
def sensor_update_to_bluetooth_data_update(
sensor_update,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass_device_info(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
_device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
device_key.key
]
for device_key in sensor_update.entity_descriptions
if device_key.key in SENSOR_DESCRIPTIONS
},
entity_data={
_device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.entity_values.items()
if device_key.key in SENSOR_DESCRIPTIONS
},
entity_names={},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Victron BLE sensor."""
coordinator = entry.runtime_data
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
VictronBLESensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
"""Representation of Victron BLE sensor."""
entity_description: VictronBLESensorEntityDescription
@property
def native_value(self) -> float | int | str | None:
"""Return the state of the sensor."""
value = self.processor.entity_data.get(self.entity_key)
return self.entity_description.value_fn(value)

View File

@@ -0,0 +1,234 @@
{
"common": {
"high_voltage": "High voltage",
"low_voltage": "Low voltage",
"midpoint_voltage": "Midpoint voltage",
"starter_voltage": "Starter voltage"
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_access_token": "Invalid encryption key for instant readout",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"flow_title": "{title}",
"step": {
"access_token": {
"data": {
"access_token": "The encryption key for instant readout of the Victron device."
},
"data_description": {
"access_token": "The encryption key for instant readout may be found in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > Encryption Key."
},
"title": "{title}"
},
"user": {
"data": {
"address": "The Bluetooth address of the Victron device."
},
"data_description": {
"address": "This Bluetooth address is automatically discovered. You may view a device's Bluetooth address in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > MAC Address."
}
}
}
},
"entity": {
"sensor": {
"ac_in_power": {
"name": "AC-in power"
},
"ac_in_state": {
"name": "AC-in state",
"state": {
"ac_in_1": "AC-in 1",
"ac_in_2": "AC-in 2",
"not_connected": "Not connected"
}
},
"ac_out_power": {
"name": "AC-out power"
},
"alarm": {
"name": "Alarm",
"state": {
"bms_lockout": "Battery management system lockout",
"dc_ripple": "DC ripple",
"high_starter_voltage": "High starter voltage",
"high_temperature": "High temperature",
"high_v_ac_out": "AC-out overvoltage",
"high_voltage": "Overvoltage",
"low_soc": "Low state of charge",
"low_starter_voltage": "Low starter voltage",
"low_temperature": "Low temperature",
"low_v_ac_out": "AC-out undervoltage",
"low_voltage": "Undervoltage",
"mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]",
"overload": "Overload",
"short_circuit": "Short circuit"
}
},
"balancer_status": {
"name": "Balancer status",
"state": {
"balanced": "Balanced",
"balancing": "Balancing",
"imbalance": "Imbalance"
}
},
"battery_current": {
"name": "Battery current"
},
"battery_temperature": {
"name": "Battery temperature"
},
"battery_voltage": {
"name": "Battery voltage"
},
"cell_voltage": {
"name": "Cell {cell} voltage"
},
"charger_error": {
"name": "Charger error",
"state": {
"bms": "BMS connection lost",
"bulk_time": "Bulk time limit exceeded",
"calibration_lost": "Factory calibration data lost",
"communication": "Communication warning",
"converter_issue": "Converter issue",
"cpu_temperature": "CPU temperature too high",
"current_sensor": "Current sensor issue",
"fan": "Fan failure",
"firmware": "Invalid or incompatible firmware",
"high_ripple": "Battery high ripple voltage",
"input_current": "Input overcurrent",
"input_power": "Input overpower",
"input_shutdown_current": "Input shutdown (current flow during off mode)",
"input_shutdown_failure": "PV input failed to shutdown",
"input_shutdown_voltage": "Input shutdown (battery overvoltage)",
"input_voltage": "Input overvoltage",
"internal_dc_voltage": "Internal DC voltage error",
"internal_supply": "Internal supply voltage error",
"internal_temperature": "Internal temperature sensor failure",
"inverter_ac": "Inverter AC voltage on output",
"inverter_output_voltage": "Inverter output voltage",
"inverter_overload": "Inverter overload",
"inverter_peak_current": "Inverter peak current",
"inverter_self_test": "Inverter self-test failed",
"inverter_shutdown_ground_fault": "Inverter shutdown (Ground fault)",
"inverter_shutdown_pv_isolation": "Inverter shutdown (PV isolation)",
"inverter_temperature": "Inverter temperature too high",
"network": "Network misconfigured",
"no_error": "No error",
"over_charge": "Overcharge protection",
"over_current": "Charger overcurrent",
"overheated": "Terminals overheated",
"pv_input_shutdown": "PV input shutdown",
"remote_battery": "Remote battery voltage sense failure",
"remote_temperature_auto_reset": "Remote temperature sensor failure (auto-reset)",
"remote_temperature_not_auto_reset": "Remote temperature sensor failure (not auto-reset)",
"self_test": "PV residual current sensor self-test failure",
"settings": "Settings data lost",
"short_circuit": "Charger short circuit",
"synchronisation": "Synchronized charging device configuration issue",
"temperature_battery_high": "Battery temperature too high",
"temperature_battery_low": "Battery temperature too low",
"temperature_charger": "Charger temperature too high",
"tester_fail": "Tester fail",
"voltage_high": "Battery overvoltage"
}
},
"consumed_ampere_hours": {
"name": "Consumed ampere hours"
},
"device_state": {
"name": "Device state",
"state": {
"absorption": "Absorption",
"active": "Active",
"battery_safe": "Battery safe",
"bulk": "Bulk",
"equalize_manual": "Equalize (manual)",
"external_control": "External control",
"fault": "Fault",
"float": "Float",
"inverting": "Inverting",
"low_power": "Low power",
"not_available": "Not available",
"off": "[%key:common::state::off%]",
"power_supply": "Power supply",
"recondition": "Recondition",
"repeated_absorption": "Repeated absorption",
"starting_up": "Starting up",
"storage": "Storage"
}
},
"error_code": {
"name": "Error code"
},
"external_device_load": {
"name": "External device load"
},
"input_voltage": {
"name": "Input voltage"
},
"meter_type": {
"name": "Meter type",
"state": {
"ac_charger": "AC charger",
"alternator": "Alternator",
"bilge_pump": "Bilge pump",
"dc_dc_charger": "DC-DC charger",
"dc_system": "DC system",
"electric_drive": "Electric drive",
"fridge": "Fridge",
"fuel_cell": "Fuel cell",
"generic_load": "Generic load",
"generic_source": "Generic source",
"inverter": "Inverter",
"shaft_generator": "Shaft generator",
"solar_charger": "Solar charger",
"water_generator": "Water generator",
"water_heater": "Water heater",
"water_pump": "Water pump",
"wind_charger": "Wind charger"
}
},
"midpoint_voltage": {
"name": "[%key:component::victron_ble::common::midpoint_voltage%]"
},
"off_reason": {
"name": "Off reason",
"state": {
"analysing_input_voltage": "Analyzing input voltage",
"bms": "Battery management system",
"engine_shutdown": "Engine shutdown",
"no_input_power": "No input power",
"no_reason": "No reason",
"pay_as_you_go_out_of_credit": "Pay-as-you-go out of credit",
"protection_active": "Protection active",
"remote_input": "Remote input",
"switched_off_register": "Switched off by register",
"switched_off_switch": "Switched off by switch"
}
},
"output_voltage": {
"name": "Output voltage"
},
"remaining_minutes": {
"name": "Remaining minutes"
},
"solar_power": {
"name": "Solar power"
},
"starter_voltage": {
"name": "[%key:component::victron_ble::common::starter_voltage%]"
},
"warning": {
"name": "Warning"
},
"yield_today": {
"name": "Yield today"
}
}
}
}

View File

@@ -849,6 +849,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"manufacturer_id": 34714,
"service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb",
},
{
"connectable": False,
"domain": "victron_ble",
"manufacturer_data_start": [
16,
],
"manufacturer_id": 737,
},
{
"connectable": False,
"domain": "xiaomi_ble",

View File

@@ -723,6 +723,7 @@ FLOWS = {
"version",
"vesync",
"vicare",
"victron_ble",
"victron_remote_monitoring",
"vilfo",
"vizio",

View File

@@ -7282,11 +7282,22 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"victron_remote_monitoring": {
"name": "Victron Remote Monitoring",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
"victron": {
"name": "Victron",
"integrations": {
"victron_ble": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Victron BLE"
},
"victron_remote_monitoring": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Victron Remote Monitoring"
}
}
},
"vilfo": {
"name": "Vilfo Router",

View File

@@ -68,8 +68,8 @@ class AreasRegistryStoreData(TypedDict):
class EventAreaRegistryUpdatedData(TypedDict):
"""EventAreaRegistryUpdated data."""
action: Literal["create", "remove", "update"]
area_id: str
action: Literal["create", "remove", "update", "reorder"]
area_id: str | None
@dataclass(frozen=True, kw_only=True, slots=True)
@@ -420,6 +420,26 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
self.async_schedule_save()
return new
@callback
def async_reorder(self, area_ids: list[str]) -> None:
"""Reorder areas."""
self.hass.verify_event_loop_thread("area_registry.async_reorder")
if set(area_ids) != set(self.areas.data.keys()):
raise ValueError(
"The area_ids list must contain all existing area IDs exactly once"
)
reordered_data = {area_id: self.areas.data[area_id] for area_id in area_ids}
self.areas.data.clear()
self.areas.data.update(reordered_data)
self.async_schedule_save()
self.hass.bus.async_fire_internal(
EVENT_AREA_REGISTRY_UPDATED,
EventAreaRegistryUpdatedData(action="reorder", area_id=None),
)
async def async_load(self) -> None:
"""Load the area registry."""
self._async_setup_cleanup()
@@ -489,7 +509,9 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
@callback
def _handle_floor_registry_update(event: fr.EventFloorRegistryUpdated) -> None:
"""Update areas that are associated with a floor that has been removed."""
floor_id = event.data["floor_id"]
floor_id = event.data.get("floor_id")
if floor_id is None:
return
for area in self.areas.get_areas_for_floor(floor_id):
self.async_update(area.id, floor_id=None)

View File

@@ -54,8 +54,8 @@ class FloorRegistryStoreData(TypedDict):
class EventFloorRegistryUpdatedData(TypedDict):
"""Event data for when the floor registry is updated."""
action: Literal["create", "remove", "update"]
floor_id: str
action: Literal["create", "remove", "update", "reorder"]
floor_id: str | None
type EventFloorRegistryUpdated = Event[EventFloorRegistryUpdatedData]
@@ -261,6 +261,28 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
return new
@callback
def async_reorder(self, floor_ids: list[str]) -> None:
"""Reorder floors."""
self.hass.verify_event_loop_thread("floor_registry.async_reorder")
if set(floor_ids) != set(self.floors.data.keys()):
raise ValueError(
"The floor_ids list must contain all existing floor IDs exactly once"
)
reordered_data = {
floor_id: self.floors.data[floor_id] for floor_id in floor_ids
}
self.floors.data.clear()
self.floors.data.update(reordered_data)
self.async_schedule_save()
self.hass.bus.async_fire_internal(
EVENT_FLOOR_REGISTRY_UPDATED,
EventFloorRegistryUpdatedData(action="reorder", floor_id=None),
)
async def async_load(self) -> None:
"""Load the floor registry."""
data = await self._store.async_load()

21
requirements_all.txt generated
View File

@@ -80,7 +80,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.13.1
PySrDaliGateway==0.16.2
# homeassistant.components.switchbot
PySwitchbot==0.73.0
@@ -209,7 +209,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.7.0
aioautomower==2.7.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -389,7 +389,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.17.0
aioshelly==13.19.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -422,7 +422,7 @@ aiotedee==0.2.25
aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==87
aiounifi==88
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -1601,7 +1601,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.5.2
ohme==1.6.0
# homeassistant.components.ollama
ollama==0.5.1
@@ -1613,7 +1613,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.16
onedrive-personal-sdk==0.0.17
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -2150,7 +2150,7 @@ pylitejet==0.6.3
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.25.0
pylutron-caseta==0.26.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -2269,7 +2269,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.5
# homeassistant.components.pglab
pypglab==0.0.5
@@ -3054,7 +3054,7 @@ unifi_ap==0.0.2
unifiled==0.11
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.1.0
universal-silabs-flasher==0.1.2
# homeassistant.components.upb
upb-lib==0.6.1
@@ -3088,6 +3088,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

@@ -77,7 +77,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.sunricher_dali
PySrDaliGateway==0.13.1
PySrDaliGateway==0.16.2
# homeassistant.components.switchbot
PySwitchbot==0.73.0
@@ -197,7 +197,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.7.0
aioautomower==2.7.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.17.0
aioshelly==13.19.0
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -404,7 +404,7 @@ aiotedee==0.2.25
aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==87
aiounifi==88
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -1372,7 +1372,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
ohme==1.5.2
ohme==1.6.0
# homeassistant.components.ollama
ollama==0.5.1
@@ -1384,7 +1384,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.16
onedrive-personal-sdk==0.0.17
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
@@ -1794,7 +1794,7 @@ pylitejet==0.6.3
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.25.0
pylutron-caseta==0.26.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20
pypaperless==4.1.1
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.5
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2521,7 +2521,7 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.1.0
universal-silabs-flasher==0.1.2
# homeassistant.components.upb
upb-lib==0.6.1
@@ -2555,6 +2555,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

@@ -1608,12 +1608,16 @@ def mock_integration(
top_level_files: set[str] | None = None,
) -> loader.Integration:
"""Mock an integration."""
integration = loader.Integration(
hass,
path = (
f"{loader.PACKAGE_BUILTIN}.{module.DOMAIN}"
if built_in
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}",
pathlib.Path(""),
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}"
)
integration = loader.Integration(
hass,
path,
pathlib.Path(path.replace(".", "/")),
module.mock_manifest(),
top_level_files,
)

View File

@@ -1,6 +1,7 @@
"""The tests for the analytics ."""
from collections.abc import Generator
from datetime import timedelta
from http import HTTPStatus
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@@ -22,8 +23,10 @@ from homeassistant.components.analytics.analytics import (
from homeassistant.components.analytics.const import (
ANALYTICS_ENDPOINT_URL,
ANALYTICS_ENDPOINT_URL_DEV,
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
ATTR_BASE,
ATTR_DIAGNOSTICS,
ATTR_SNAPSHOTS,
ATTR_STATISTICS,
ATTR_USAGE,
)
@@ -31,13 +34,20 @@ from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import ATTR_ASSUMED_STATE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, ReleaseChannel
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.loader import IntegrationNotFound
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform
from tests.common import (
MockConfigEntry,
MockModule,
async_fire_time_changed,
mock_integration,
mock_platform,
)
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -59,9 +69,31 @@ def uuid_mock() -> Generator[None]:
@pytest.fixture(autouse=True)
def ha_version_mock() -> Generator[None]:
"""Mock the core version."""
with patch(
"homeassistant.components.analytics.analytics.HA_VERSION",
MOCK_VERSION,
with (
patch(
"homeassistant.components.analytics.analytics.HA_VERSION",
MOCK_VERSION,
),
patch(
"homeassistant.components.analytics.analytics.RELEASE_CHANNEL",
ReleaseChannel.STABLE,
),
):
yield
@pytest.fixture
def ha_dev_version_mock() -> Generator[None]:
"""Mock the core version as a dev version."""
with (
patch(
"homeassistant.components.analytics.analytics.HA_VERSION",
MOCK_VERSION_DEV,
),
patch(
"homeassistant.components.analytics.analytics.RELEASE_CHANNEL",
ReleaseChannel.DEV,
),
):
yield
@@ -97,7 +129,6 @@ async def test_no_send(
await analytics.send_analytics()
assert "Nothing to submit" in caplog.text
assert len(aioclient_mock.mock_calls) == 0
@@ -615,7 +646,7 @@ async def test_custom_integrations(
assert snapshot == submitted_data
@pytest.mark.usefixtures("supervisor_client")
@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client")
async def test_dev_url(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
@@ -625,16 +656,13 @@ async def test_dev_url(
analytics = Analytics(hass)
await analytics.save_preferences({ATTR_BASE: True})
with patch(
"homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV
):
await analytics.send_analytics()
await analytics.send_analytics()
payload = aioclient_mock.mock_calls[0]
assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV
@pytest.mark.usefixtures("supervisor_client")
@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client")
async def test_dev_url_error(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
@@ -645,10 +673,7 @@ async def test_dev_url_error(
analytics = Analytics(hass)
await analytics.save_preferences({ATTR_BASE: True})
with patch(
"homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV
):
await analytics.send_analytics()
await analytics.send_analytics()
payload = aioclient_mock.mock_calls[0]
assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV
@@ -860,7 +885,7 @@ async def test_send_with_problems_loading_yaml(
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.usefixtures("mock_hass_config", "supervisor_client")
@pytest.mark.usefixtures("ha_dev_version_mock", "mock_hass_config", "supervisor_client")
async def test_timeout_while_sending(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
@@ -871,10 +896,7 @@ async def test_timeout_while_sending(
aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, exc=TimeoutError())
await analytics.save_preferences({ATTR_BASE: True})
with patch(
"homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION_DEV
):
await analytics.send_analytics()
await analytics.send_analytics()
assert "Timeout sending analytics" in caplog.text
@@ -1426,3 +1448,346 @@ async def test_analytics_platforms(
},
},
}
async def test_send_snapshot_disabled(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test no snapshots are sent."""
analytics = Analytics(hass)
await analytics.send_snapshot()
await analytics.save_preferences({ATTR_SNAPSHOTS: False})
await analytics.send_snapshot()
assert len(aioclient_mock.mock_calls) == 0
async def test_send_snapshot_success(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test successful snapshot submission."""
aioclient_mock.post(
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
status=200,
json={"submission_identifier": "test-identifier-123"},
)
analytics = Analytics(hass)
await analytics.save_preferences({ATTR_SNAPSHOTS: True})
await analytics.send_snapshot()
assert len(aioclient_mock.mock_calls) == 1
preferences = await analytics._store.async_load()
assert preferences["submission_identifier"] == "test-identifier-123"
assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text
async def test_send_snapshot_with_existing_identifier(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test snapshot submission with existing identifier."""
aioclient_mock.post(
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
status=200,
json={"submission_identifier": "test-identifier-123"},
)
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
"submission_identifier": "old-identifier",
},
):
await analytics.load()
await analytics.send_snapshot()
assert len(aioclient_mock.mock_calls) == 1
call_headers = aioclient_mock.mock_calls[0][3]
assert call_headers["X-Device-Database-Submission-Identifier"] == "old-identifier"
preferences = await analytics._store.async_load()
assert preferences["submission_identifier"] == "test-identifier-123"
assert "Submitted snapshot analytics to Home Assistant servers" in caplog.text
async def test_send_snapshot_invalid_identifier(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test snapshot submission with invalid identifier."""
aioclient_mock.post(
ANALYTICS_SNAPSHOT_ENDPOINT_URL,
status=400,
json={
"kind": "invalid-submission-identifier",
"message": "The identifier is invalid",
},
)
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
"submission_identifier": "invalid-identifier",
},
):
await analytics.load()
await analytics.send_snapshot()
assert len(aioclient_mock.mock_calls) == 1
preferences = await analytics._store.async_load()
assert preferences.get("submission_identifier") is None
assert "Invalid submission identifier" in caplog.text
@pytest.mark.parametrize(
("post_kwargs", "expected_log"),
[
(
{
"status": 400,
"json": {
"kind": "malformed-payload",
"message": "Invalid payload format",
},
},
"Malformed snapshot analytics submission",
),
(
{"status": 503, "text": "Service Unavailable"},
f"Snapshot analytics service {ANALYTICS_SNAPSHOT_ENDPOINT_URL} unavailable",
),
(
{"status": 500},
"Unexpected status code 500 when submitting snapshot analytics",
),
(
{"exc": TimeoutError()},
"Timeout sending snapshot analytics",
),
(
{"exc": aiohttp.ClientError()},
"Error sending snapshot analytics",
),
],
ids=[
"bad_request",
"service_unavailable",
"unexpected_status",
"timeout",
"client_error",
],
)
async def test_send_snapshot_error(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
aioclient_mock: AiohttpClientMocker,
post_kwargs: dict[str, Any],
expected_log: str,
) -> None:
"""Test snapshot submission error."""
aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, **post_kwargs)
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
},
):
await analytics.load()
await analytics.send_snapshot()
assert expected_log in caplog.text
@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client")
async def test_async_schedule(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test scheduling."""
aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200)
aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, status=200, json={})
analytics = Analytics(hass)
# Schedule when not onboarded
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 0
# Onboard and enable both
await analytics.save_preferences({ATTR_BASE: True, ATTR_SNAPSHOTS: True})
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert any(
str(call[1]) == ANALYTICS_ENDPOINT_URL_DEV for call in aioclient_mock.mock_calls
)
assert any(
str(call[1]) == ANALYTICS_SNAPSHOT_ENDPOINT_URL
for call in aioclient_mock.mock_calls
)
preferences = await analytics._store.async_load()
assert preferences["snapshot_submission_time"] is not None
assert 0 <= preferences["snapshot_submission_time"] <= 86400
@pytest.mark.usefixtures("ha_dev_version_mock")
async def test_async_schedule_disabled(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test scheduling when disabled."""
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: False},
"uuid": "12345",
},
):
await analytics.load()
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 0
@pytest.mark.usefixtures("supervisor_client")
async def test_async_schedule_snapshots_not_dev(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test that snapshots are not scheduled on non-dev versions."""
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
},
):
await analytics.load()
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 1
assert str(aioclient_mock.mock_calls[0][1]) == ANALYTICS_ENDPOINT_URL
@pytest.mark.usefixtures("ha_dev_version_mock", "supervisor_client")
async def test_async_schedule_already_scheduled(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test not rescheduled if already scheduled."""
aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200)
aioclient_mock.post(ANALYTICS_SNAPSHOT_ENDPOINT_URL, status=200, json={})
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
},
):
await analytics.load()
await analytics.async_schedule()
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 2
assert any(
str(call[1]) == ANALYTICS_ENDPOINT_URL_DEV for call in aioclient_mock.mock_calls
)
assert any(
str(call[1]) == ANALYTICS_SNAPSHOT_ENDPOINT_URL
for call in aioclient_mock.mock_calls
)
@pytest.mark.parametrize(("onboarded"), [True, False])
@pytest.mark.usefixtures("ha_dev_version_mock")
async def test_async_schedule_cancel_when_disabled(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
onboarded: bool,
) -> None:
"""Test that scheduled tasks are cancelled when disabled."""
analytics = Analytics(hass)
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": True,
"preferences": {ATTR_BASE: True, ATTR_SNAPSHOTS: True},
"uuid": "12345",
},
):
await analytics.load()
await analytics.async_schedule()
with patch(
"homeassistant.helpers.storage.Store.async_load",
return_value={
"onboarded": onboarded,
"preferences": {ATTR_BASE: False, ATTR_SNAPSHOTS: False},
"uuid": "12345",
},
):
await analytics.load()
await analytics.async_schedule()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
await hass.async_block_till_done()
assert len(aioclient_mock.mock_calls) == 0

View File

@@ -45,7 +45,6 @@ async def test_websocket(
{"type": "analytics/preferences", "preferences": {"base": True}}
)
response = await ws_client.receive_json()
assert len(aioclient_mock.mock_calls) == 1
assert response["result"]["preferences"]["base"]
await ws_client.send_json_auto_id({"type": "analytics"})

View File

@@ -1,4 +1,244 @@
# serializer version: 1
# name: test_deprecated_sensor_issue[apc-apc_deprecated]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'apc_deprecated_sensor.myups_status_data',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'apc_deprecated',
'translation_placeholders': dict({
'device_id': '<ANY>',
'entity_id': 'sensor.myups_status_data',
'entity_name': 'Status data',
'items': '''
- [APC UPS automation (apc)](/config/automation/edit/apcupsd_auto_apc)
- [APC UPS script (apc)](/config/script/edit/apcupsd_script_apc)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[apcmodel-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_model',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'model',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_model',
'entity_name': 'Model',
'items': '''
- [APC UPS automation (apcmodel)](/config/automation/edit/apcupsd_auto_apcmodel)
- [APC UPS script (apcmodel)](/config/script/edit/apcupsd_script_apcmodel)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[date-date_deprecated]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'date_deprecated_sensor.myups_status_date',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'date_deprecated',
'translation_placeholders': dict({
'device_id': '<ANY>',
'entity_id': 'sensor.myups_status_date',
'entity_name': 'Status date',
'items': '''
- [APC UPS automation (date)](/config/automation/edit/apcupsd_auto_date)
- [APC UPS script (date)](/config/script/edit/apcupsd_script_date)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[end apc-date_deprecated]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'date_deprecated_sensor.myups_date_and_time',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'date_deprecated',
'translation_placeholders': dict({
'device_id': '<ANY>',
'entity_id': 'sensor.myups_date_and_time',
'entity_name': 'Date and time',
'items': '''
- [APC UPS automation (end apc)](/config/automation/edit/apcupsd_auto_end_apc)
- [APC UPS script (end apc)](/config/script/edit/apcupsd_script_end_apc)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[firmware-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_firmware_version',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'hw_version',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_firmware_version',
'entity_name': 'Firmware version',
'items': '''
- [APC UPS automation (firmware)](/config/automation/edit/apcupsd_auto_firmware)
- [APC UPS script (firmware)](/config/script/edit/apcupsd_script_firmware)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[model-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_model_2',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'model',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_model_2',
'entity_name': 'Model',
'items': '''
- [APC UPS automation (model)](/config/automation/edit/apcupsd_auto_model)
- [APC UPS script (model)](/config/script/edit/apcupsd_script_model)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[serialno-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_serial_number',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'serial_number',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_serial_number',
'entity_name': 'Serial number',
'items': '''
- [APC UPS automation (serialno)](/config/automation/edit/apcupsd_auto_serialno)
- [APC UPS script (serialno)](/config/script/edit/apcupsd_script_serialno)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[upsname-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_name',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'name',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_name',
'entity_name': 'Name',
'items': '''
- [APC UPS automation (upsname)](/config/automation/edit/apcupsd_auto_upsname)
- [APC UPS script (upsname)](/config/script/edit/apcupsd_script_upsname)
''',
}),
})
# ---
# name: test_deprecated_sensor_issue[version-available_via_device_info]
IssueRegistryItemSnapshot({
'active': True,
'breaks_in_ha_version': '2026.6.0',
'created': <ANY>,
'data': None,
'dismissed_version': None,
'domain': 'apcupsd',
'is_fixable': False,
'is_persistent': False,
'issue_domain': None,
'issue_id': 'available_via_device_info_sensor.myups_daemon_version',
'learn_more_url': None,
'severity': <IssueSeverity.WARNING: 'warning'>,
'translation_key': 'available_via_device_info',
'translation_placeholders': dict({
'available_via_device_attr': 'sw_version',
'device_id': '<ANY>',
'entity_id': 'sensor.myups_daemon_version',
'entity_name': 'Daemon version',
'items': '''
- [APC UPS automation (version)](/config/automation/edit/apcupsd_auto_version)
- [APC UPS script (version)](/config/script/edit/apcupsd_script_version)
''',
}),
})
# ---
# name: test_sensor[sensor.myups_alarm_delay-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -6,7 +6,8 @@ from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.apcupsd.const import DOMAIN
from homeassistant.components import automation, script
from homeassistant.components.apcupsd.const import DEPRECATED_SENSORS, DOMAIN
from homeassistant.components.apcupsd.coordinator import REQUEST_REFRESH_COOLDOWN
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -15,7 +16,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.setup import async_setup_component
from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
@@ -161,3 +166,76 @@ async def test_sensor_unknown(
await hass.async_block_till_done()
# The state should become unknown again.
assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN
@pytest.mark.parametrize(("entity_key", "issue_key"), DEPRECATED_SENSORS.items())
async def test_deprecated_sensor_issue(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_request_status: AsyncMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
entity_key: str,
issue_key: str,
) -> None:
"""Ensure the issue lists automations and scripts referencing a deprecated sensor."""
issue_registry = ir.async_get(hass)
unique_id = f"{mock_request_status.return_value['SERIALNO']}_{entity_key}"
entity_id = entity_registry.async_get_entity_id("sensor", DOMAIN, unique_id)
assert entity_id
# No issue yet.
issue_id = f"{issue_key}_{entity_id}"
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
# Add automations and scripts referencing the deprecated sensor.
entity_slug = slugify(entity_key)
automation_object_id = f"apcupsd_auto_{entity_slug}"
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"id": automation_object_id,
"alias": f"APC UPS automation ({entity_key})",
"trigger": {"platform": "state", "entity_id": entity_id},
"action": {
"action": "automation.turn_on",
"target": {"entity_id": f"automation.{automation_object_id}"},
},
}
},
)
assert await async_setup_component(
hass,
script.DOMAIN,
{
script.DOMAIN: {
f"apcupsd_script_{entity_slug}": {
"alias": f"APC UPS script ({entity_key})",
"sequence": [
{
"condition": "state",
"entity_id": entity_id,
"state": "on",
}
],
}
}
},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(DOMAIN, issue_id)
# Redact the device ID in the placeholder for consistency.
issue.translation_placeholders["device_id"] = "<ANY>"
assert issue == snapshot
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Assert the issue is no longer present.
assert not issue_registry.async_get_issue(DOMAIN, issue_id)
assert len(issue_registry.issues) == 0

View File

@@ -5,9 +5,7 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def init_integration(
hass: HomeAssistant, entry: MockConfigEntry
) -> MockConfigEntry:
async def init_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the Brother integration in Home Assistant."""
entry.add_to_hass(hass)

View File

@@ -90,15 +90,6 @@ def mock_setup_entry() -> Generator[AsyncMock]:
yield mock_setup_entry
@pytest.fixture
def mock_unload_entry() -> Generator[AsyncMock]:
"""Override async_unload_entry."""
with patch(
"homeassistant.components.brother.async_unload_entry", return_value=True
) as mock_unload_entry:
yield mock_unload_entry
@pytest.fixture
def mock_brother() -> Generator[AsyncMock]:
"""Mock the Brother class."""

View File

@@ -1,7 +1,7 @@
"""Define tests for the Brother Printer config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from brother import SnmpError, UnsupportedModelError
import pytest
@@ -27,17 +27,7 @@ CONFIG = {
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry")
async def test_show_form(hass: HomeAssistant) -> None:
"""Test that the form is served with no input."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize("host", ["example.local", "127.0.0.1", "2001:db8::1428:57ab"])
@@ -49,9 +39,15 @@ async def test_create_entry(
config[CONF_HOST] = host
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=config,
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"],
user_input=config,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -60,6 +56,7 @@ async def test_create_entry(
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
assert result["result"].unique_id == "0123456789"
async def test_invalid_hostname(
@@ -97,6 +94,7 @@ async def test_invalid_hostname(
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
assert result["result"].unique_id == "0123456789"
@pytest.mark.parametrize(
@@ -142,6 +140,7 @@ async def test_errors(
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
assert result["result"].unique_id == "0123456789"
async def test_unsupported_model_error(
@@ -250,32 +249,31 @@ async def test_zeroconf_device_exists_abort(
assert mock_config_entry.data[CONF_HOST] == "127.0.0.1"
async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None:
async def test_zeroconf_no_probe_existing_device(
hass: HomeAssistant, mock_brother_client: AsyncMock
) -> None:
"""Test we do not probe the device is the host is already configured."""
entry = MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG)
entry.add_to_hass(hass)
with (
patch("homeassistant.components.brother.Brother.initialize"),
patch("homeassistant.components.brother.Brother._get_data") as mock_get_data,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
name="Brother Printer",
port=None,
properties={},
type="mock_type",
),
)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="example.local.",
name="Brother Printer",
port=None,
properties={},
type="mock_type",
),
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(mock_get_data.mock_calls) == 0
mock_brother_client.async_update.assert_not_called()
async def test_zeroconf_confirm_create_entry(
@@ -315,6 +313,7 @@ async def test_zeroconf_confirm_create_entry(
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
assert result["result"].unique_id == "0123456789"
async def test_reconfigure_successful(

View File

@@ -1,6 +1,7 @@
"""Test area_registry API."""
from datetime import datetime
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -346,3 +347,92 @@ async def test_update_area_with_name_already_in_use(
assert msg["error"]["code"] == "invalid_info"
assert msg["error"]["message"] == "The name mock 2 (mock2) is already in use"
assert len(area_registry.areas) == 2
async def test_reorder_areas(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
) -> None:
"""Test reorder areas."""
area1 = area_registry.async_create("mock 1")
area2 = area_registry.async_create("mock 2")
area3 = area_registry.async_create("mock 3")
await client.send_json_auto_id({"type": "config/area_registry/list"})
msg = await client.receive_json()
assert [area["area_id"] for area in msg["result"]] == [area1.id, area2.id, area3.id]
await client.send_json_auto_id(
{
"type": "config/area_registry/reorder",
"area_ids": [area3.id, area1.id, area2.id],
}
)
msg = await client.receive_json()
assert msg["success"]
await client.send_json_auto_id({"type": "config/area_registry/list"})
msg = await client.receive_json()
assert [area["area_id"] for area in msg["result"]] == [area3.id, area1.id, area2.id]
async def test_reorder_areas_invalid_area_ids(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
) -> None:
"""Test reorder with invalid area IDs."""
area1 = area_registry.async_create("mock 1")
area_registry.async_create("mock 2")
await client.send_json_auto_id(
{
"type": "config/area_registry/reorder",
"area_ids": [area1.id],
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_format"
assert "must contain all existing area IDs" in msg["error"]["message"]
async def test_reorder_areas_with_nonexistent_id(
client: MockHAClientWebSocket, area_registry: ar.AreaRegistry
) -> None:
"""Test reorder with nonexistent area ID."""
area1 = area_registry.async_create("mock 1")
area2 = area_registry.async_create("mock 2")
await client.send_json_auto_id(
{
"type": "config/area_registry/reorder",
"area_ids": [area1.id, area2.id, "nonexistent"],
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_format"
async def test_reorder_areas_persistence(
hass: HomeAssistant,
client: MockHAClientWebSocket,
area_registry: ar.AreaRegistry,
hass_storage: dict[str, Any],
) -> None:
"""Test that area reordering is persisted."""
area1 = area_registry.async_create("mock 1")
area2 = area_registry.async_create("mock 2")
area3 = area_registry.async_create("mock 3")
await client.send_json_auto_id(
{
"type": "config/area_registry/reorder",
"area_ids": [area2.id, area3.id, area1.id],
}
)
msg = await client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
area_ids = [area.id for area in area_registry.async_list_areas()]
assert area_ids == [area2.id, area3.id, area1.id]

View File

@@ -1,6 +1,7 @@
"""Test floor registry API."""
from datetime import datetime
from typing import Any
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -275,3 +276,100 @@ async def test_update_with_name_already_in_use(
== "The name Second floor (secondfloor) is already in use"
)
assert len(floor_registry.floors) == 2
async def test_reorder_floors(
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
) -> None:
"""Test reorder floors."""
floor1 = floor_registry.async_create("First floor")
floor2 = floor_registry.async_create("Second floor")
floor3 = floor_registry.async_create("Third floor")
await client.send_json_auto_id({"type": "config/floor_registry/list"})
msg = await client.receive_json()
assert [floor["floor_id"] for floor in msg["result"]] == [
floor1.floor_id,
floor2.floor_id,
floor3.floor_id,
]
await client.send_json_auto_id(
{
"type": "config/floor_registry/reorder",
"floor_ids": [floor3.floor_id, floor1.floor_id, floor2.floor_id],
}
)
msg = await client.receive_json()
assert msg["success"]
await client.send_json_auto_id({"type": "config/floor_registry/list"})
msg = await client.receive_json()
assert [floor["floor_id"] for floor in msg["result"]] == [
floor3.floor_id,
floor1.floor_id,
floor2.floor_id,
]
async def test_reorder_floors_invalid_floor_ids(
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
) -> None:
"""Test reorder with invalid floor IDs."""
floor1 = floor_registry.async_create("First floor")
floor_registry.async_create("Second floor")
await client.send_json_auto_id(
{
"type": "config/floor_registry/reorder",
"floor_ids": [floor1.floor_id],
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_format"
assert "must contain all existing floor IDs" in msg["error"]["message"]
async def test_reorder_floors_with_nonexistent_id(
client: MockHAClientWebSocket, floor_registry: fr.FloorRegistry
) -> None:
"""Test reorder with nonexistent floor ID."""
floor1 = floor_registry.async_create("First floor")
floor2 = floor_registry.async_create("Second floor")
await client.send_json_auto_id(
{
"type": "config/floor_registry/reorder",
"floor_ids": [floor1.floor_id, floor2.floor_id, "nonexistent"],
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "invalid_format"
async def test_reorder_floors_persistence(
hass: HomeAssistant,
client: MockHAClientWebSocket,
floor_registry: fr.FloorRegistry,
hass_storage: dict[str, Any],
) -> None:
"""Test that floor reordering is persisted."""
floor1 = floor_registry.async_create("First floor")
floor2 = floor_registry.async_create("Second floor")
floor3 = floor_registry.async_create("Third floor")
await client.send_json_auto_id(
{
"type": "config/floor_registry/reorder",
"floor_ids": [floor2.floor_id, floor3.floor_id, floor1.floor_id],
}
)
msg = await client.receive_json()
assert msg["success"]
await hass.async_block_till_done()
floor_ids = [floor.floor_id for floor in floor_registry.async_list_floors()]
assert floor_ids == [floor2.floor_id, floor3.floor_id, floor1.floor_id]

View File

@@ -301,3 +301,274 @@ async def test_set_user_data(
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] == "test-value"
async def test_get_system_data_empty(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test get_system_data command."""
client = await hass_ws_client(hass)
await client.send_json(
{"id": 5, "type": "frontend/get_system_data", "key": "non-existing-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] is None
async def test_get_system_data(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
) -> None:
"""Test get_system_data command."""
storage_key = f"{DOMAIN}.system_data"
hass_storage[storage_key] = {
"key": storage_key,
"version": 1,
"data": {"test-key": "test-value", "test-complex": [{"foo": "bar"}]},
}
client = await hass_ws_client(hass)
# Get a simple string key
await client.send_json(
{"id": 6, "type": "frontend/get_system_data", "key": "test-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] == "test-value"
# Get a more complex key
await client.send_json(
{"id": 7, "type": "frontend/get_system_data", "key": "test-complex"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"][0]["foo"] == "bar"
@pytest.mark.parametrize(
("subscriptions", "events"),
[
([], []),
([(1, {"key": "test-key"}, None)], [(1, "test-value")]),
([(1, {"key": "other-key"}, None)], []),
],
)
async def test_set_system_data_empty(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
subscriptions: list[tuple[int, dict[str, str], Any]],
events: list[tuple[int, Any]],
) -> None:
"""Test set_system_data command.
Also test subscribing.
"""
client = await hass_ws_client(hass)
for msg_id, key, event_data in subscriptions:
await client.send_json(
{
"id": msg_id,
"type": "frontend/subscribe_system_data",
}
| key
)
event = await client.receive_json()
assert event == {
"id": msg_id,
"type": "event",
"event": {"value": event_data},
}
res = await client.receive_json()
assert res["success"], res
# test creating
await client.send_json(
{"id": 6, "type": "frontend/get_system_data", "key": "test-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] is None
await client.send_json(
{
"id": 7,
"type": "frontend/set_system_data",
"key": "test-key",
"value": "test-value",
}
)
for msg_id, event_data in events:
event = await client.receive_json()
assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}}
res = await client.receive_json()
assert res["success"], res
await client.send_json(
{"id": 8, "type": "frontend/get_system_data", "key": "test-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] == "test-value"
@pytest.mark.parametrize(
("subscriptions", "events"),
[
(
[],
[[], []],
),
(
[(1, {"key": "test-key"}, "test-value")],
[[], []],
),
(
[(1, {"key": "test-non-existent-key"}, None)],
[[(1, "test-value-new")], []],
),
(
[(1, {"key": "test-complex"}, "string")],
[[], [(1, [{"foo": "bar"}])]],
),
(
[(1, {"key": "other-key"}, None)],
[[], []],
),
],
)
async def test_set_system_data(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_storage: dict[str, Any],
subscriptions: list[tuple[int, dict[str, str], Any]],
events: list[list[tuple[int, Any]]],
) -> None:
"""Test set_system_data command with initial data."""
storage_key = f"{DOMAIN}.system_data"
hass_storage[storage_key] = {
"version": 1,
"data": {"test-key": "test-value", "test-complex": "string"},
}
client = await hass_ws_client(hass)
for msg_id, key, event_data in subscriptions:
await client.send_json(
{
"id": msg_id,
"type": "frontend/subscribe_system_data",
}
| key
)
event = await client.receive_json()
assert event == {
"id": msg_id,
"type": "event",
"event": {"value": event_data},
}
res = await client.receive_json()
assert res["success"], res
# test creating
await client.send_json(
{
"id": 5,
"type": "frontend/set_system_data",
"key": "test-non-existent-key",
"value": "test-value-new",
}
)
for msg_id, event_data in events[0]:
event = await client.receive_json()
assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}}
res = await client.receive_json()
assert res["success"], res
await client.send_json(
{"id": 6, "type": "frontend/get_system_data", "key": "test-non-existent-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] == "test-value-new"
# test updating with complex data
await client.send_json(
{
"id": 7,
"type": "frontend/set_system_data",
"key": "test-complex",
"value": [{"foo": "bar"}],
}
)
for msg_id, event_data in events[1]:
event = await client.receive_json()
assert event == {"id": msg_id, "type": "event", "event": {"value": event_data}}
res = await client.receive_json()
assert res["success"], res
await client.send_json(
{"id": 8, "type": "frontend/get_system_data", "key": "test-complex"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"][0]["foo"] == "bar"
# ensure other existing key was not modified
await client.send_json(
{"id": 9, "type": "frontend/get_system_data", "key": "test-key"}
)
res = await client.receive_json()
assert res["success"], res
assert res["result"]["value"] == "test-value"
async def test_set_system_data_requires_admin(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_read_only_access_token: str,
) -> None:
"""Test set_system_data requires admin permissions."""
client = await hass_ws_client(hass, hass_read_only_access_token)
await client.send_json(
{
"id": 5,
"type": "frontend/set_system_data",
"key": "test-key",
"value": "test-value",
}
)
res = await client.receive_json()
assert not res["success"], res
assert res["error"]["code"] == "unauthorized"
assert res["error"]["message"] == "Unauthorized"

View File

@@ -1,7 +1,7 @@
"""Lamarzocco session fixtures."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
from bleak.backends.device import BLEDevice
from pylamarzocco.const import ModelName
@@ -132,6 +132,10 @@ def mock_lamarzocco(device_fixture: ModelName) -> Generator[MagicMock]:
"schedule": machine_mock.schedule.to_dict(),
"settings": machine_mock.settings.to_dict(),
}
machine_mock.connect_dashboard_websocket = AsyncMock()
machine_mock.websocket = MagicMock()
machine_mock.websocket.connected = True
machine_mock.websocket.disconnect = AsyncMock()
yield machine_mock
@@ -149,10 +153,11 @@ def mock_ble_device() -> BLEDevice:
@pytest.fixture
def mock_websocket_terminated() -> Generator[bool]:
def mock_websocket_terminated() -> Generator[PropertyMock]:
"""Mock websocket terminated."""
with patch(
"homeassistant.components.lamarzocco.coordinator.LaMarzoccoUpdateCoordinator.websocket_terminated",
new=False,
new_callable=PropertyMock,
) as mock_websocket_terminated:
mock_websocket_terminated.return_value = False
yield mock_websocket_terminated

View File

@@ -1,8 +1,7 @@
"""Tests for La Marzocco binary sensors."""
from collections.abc import Generator
from datetime import timedelta
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.exceptions import RequestNotSuccessful
@@ -36,24 +35,15 @@ async def test_binary_sensors(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.fixture(autouse=True)
def mock_websocket_terminated() -> Generator[bool]:
"""Mock websocket terminated."""
with patch(
"homeassistant.components.lamarzocco.coordinator.LaMarzoccoUpdateCoordinator.websocket_terminated",
new=False,
) as mock_websocket_terminated:
yield mock_websocket_terminated
async def test_brew_active_unavailable(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
mock_websocket_terminated: PropertyMock,
) -> None:
"""Test the La Marzocco brew active becomes unavailable."""
mock_lamarzocco.websocket.connected = False
mock_websocket_terminated.return_value = True
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(
f"binary_sensor.{mock_lamarzocco.serial_number}_brewing_active"

View File

@@ -1,7 +1,9 @@
"""Test initialization of lamarzocco."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.const import FirmwareType, ModelName
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from pylamarzocco.models import WebSocketDetails
@@ -30,7 +32,7 @@ from . import (
get_bluetooth_service_info,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_load_unload_config_entry(
@@ -310,3 +312,37 @@ async def test_device(
device = device_registry.async_get(entry.device_id)
assert device
assert device == snapshot
async def test_websocket_reconnects_after_termination(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_lamarzocco: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the websocket reconnects after background task terminates."""
# Setup: websocket connected initially
mock_websocket = MagicMock()
mock_websocket.closed = False
mock_lamarzocco.websocket = WebSocketDetails(mock_websocket, None)
await async_init_integration(hass, mock_config_entry)
# Verify initial websocket connection was attempted
assert mock_lamarzocco.connect_dashboard_websocket.call_count == 1
# Simulate websocket disconnection (e.g., after internet outage)
mock_websocket.closed = True
# Simulate the background task terminating by patching websocket_terminated
with patch(
"homeassistant.components.lamarzocco.coordinator.LaMarzoccoConfigUpdateCoordinator.websocket_terminated",
new=True,
):
# Trigger the coordinator's update (which runs every 60 seconds)
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Verify websocket reconnection was attempted
assert mock_lamarzocco.connect_dashboard_websocket.call_count == 2

View File

@@ -112,7 +112,7 @@ async def integration_fixture(
"leak_sensor",
"light_sensor",
"microwave_oven",
"mock_thermostat",
"mock_lock",
"mounted_dimmable_load_control_fixture",
"multi_endpoint_light",
"occupancy_sensor",

View File

@@ -1,7 +1,7 @@
{
"node_id": 150,
"date_commissioned": "2025-11-18T06:53:08.679289",
"last_interview": "2025-11-18T06:53:08.679325",
"node_id": 151,
"date_commissioned": "2025-11-18T20:53:49.656346",
"last_interview": "2025-11-18T20:53:49.656374",
"interview_version": 6,
"available": true,
"is_bridge": false,
@@ -22,6 +22,14 @@
"0/49/65528": [],
"0/49/65529": [],
"0/49/65531": [0, 1, 4, 5, 6, 7, 65532, 65533, 65528, 65529, 65531],
"0/70/0": 300,
"0/70/1": 300,
"0/70/2": 5000,
"0/70/65532": 0,
"0/70/65533": 3,
"0/70/65528": [],
"0/70/65529": [],
"0/70/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531],
"0/65/0": [],
"0/65/65532": 0,
"0/65/65533": 1,
@@ -39,7 +47,7 @@
"0/63/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/62/0": [
{
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRlhgkBwEkCAEwCUEE2p7AKvoklmZUFHB0JFUiCsv5FCm0dmeH35yXz4UUH4HAWUwpbeU+R7hMGbAITM3T1R/mVWYthssdVcPNsfIVcjcKNQEoARgkAgE2AwQCBAEYMAQUQbZ3toX8hpE/FmJz7M6xHTbh6RMwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0DughBITJJHW/pS7o0J6o6FYTe1ufe0vCpaCj3qYeWb/QxLUydUaJQbce5Z3lUcFeHybUa/M9HID+0PRp2Ker3/GA==",
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRlxgkBwEkCAEwCUEEXezptKw8pXZnNDtNP/6ytcTdilcvU5tGmhRizeaObdQklNW6TLSBvoX5icYne5L5ESsO6yuHCKieC120pOsVVjcKNQEoARgkAgE2AwQCBAEYMAQUtiskyCanVk/hj/Gf0pOAxSBXGLAwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0DrKZjxUW15mceAFXSKE0BCJf3c4iBeFXaK9nrYnHOfiOH9QYqZ71iUyvOrlahFR9xELuLKaj5j/NlVR3CVGU/KGA==",
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY",
"254": 1
}
@@ -49,7 +57,7 @@
"1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=",
"2": 4939,
"3": 2,
"4": 150,
"4": 151,
"5": "ha",
"254": 1
}
@@ -73,15 +81,15 @@
"0/60/65528": [],
"0/60/65529": [0, 2],
"0/60/65531": [0, 1, 2, 65532, 65533, 65528, 65529, 65531],
"0/55/2": 425,
"0/55/3": 61,
"0/55/2": 894,
"0/55/3": 64,
"0/55/4": 0,
"0/55/5": 0,
"0/55/6": 0,
"0/55/7": null,
"0/55/1": true,
"0/55/0": 2,
"0/55/8": 16,
"0/55/8": 19,
"0/55/65532": 3,
"0/55/65533": 1,
"0/55/65528": [],
@@ -112,29 +120,29 @@
],
"0/52/0": [
{
"0": 6163,
"1": "6163"
"0": 12306,
"1": "12306"
},
{
"0": 6162,
"1": "6162"
"0": 12305,
"1": "12305"
},
{
"0": 6161,
"1": "6161"
"0": 12304,
"1": "12304"
},
{
"0": 6160,
"1": "6160"
"0": 12303,
"1": "12303"
},
{
"0": 6159,
"1": "6159"
"0": 12302,
"1": "12302"
}
],
"0/52/1": 545392,
"0/52/2": 650640,
"0/52/3": 650640,
"0/52/1": 537280,
"0/52/2": 662848,
"0/52/3": 662848,
"0/52/65532": 1,
"0/52/65533": 1,
"0/52/65528": [],
@@ -159,7 +167,7 @@
"4": "AAwpaqXN",
"5": ["wKgBxA=="],
"6": [
"KgEOCgKzOZAcmuLd4EsaUA==",
"KgEOCgKzOZBI/+PPpfBv9Q==",
"KgEOCgKzOZA2wMm9YG06Ag==",
"/oAAAAAAAACluAo+qvkuxw=="
],
@@ -180,7 +188,7 @@
"0/51/8": false,
"0/51/3": 0,
"0/51/4": 0,
"0/51/2": 16,
"0/51/2": 19,
"0/51/65532": 0,
"0/51/65533": 2,
"0/51/65528": [2],
@@ -204,17 +212,10 @@
"0/48/65528": [1, 3, 5],
"0/48/65529": [0, 2, 4],
"0/48/65531": [0, 1, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
"0/43/0": "en-US",
"0/43/1": ["en-US"],
"0/43/65532": 0,
"0/43/65533": 1,
"0/43/65528": [],
"0/43/65529": [],
"0/43/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"0/40/0": 19,
"0/40/1": "TEST_VENDOR",
"0/40/2": 65521,
"0/40/3": "Mock Thermostat",
"0/40/3": "Mock Lock",
"0/40/4": 32769,
"0/40/5": "",
"0/40/6": "**REDACTED**",
@@ -235,7 +236,7 @@
"0/40/14": "",
"0/40/15": "TEST_SN",
"0/40/16": false,
"0/40/18": "29DB8B9DB518F05F",
"0/40/18": "714373B6E9864224",
"0/40/65532": 0,
"0/40/65533": 5,
"0/40/65528": [],
@@ -256,30 +257,24 @@
"0/31/2": 4,
"0/31/3": 3,
"0/31/4": 4,
"0/31/65532": 0,
"0/31/1": [],
"0/31/65532": 1,
"0/31/65533": 3,
"0/31/65528": [],
"0/31/65529": [],
"0/31/65531": [0, 2, 3, 4, 65532, 65533, 65528, 65529, 65531],
"0/30/0": [],
"0/30/65532": 0,
"0/30/65533": 1,
"0/30/65528": [],
"0/30/65529": [],
"0/30/65531": [0, 65532, 65533, 65528, 65529, 65531],
"0/31/65531": [0, 2, 3, 4, 1, 65532, 65533, 65528, 65529, 65531],
"0/29/0": [
{
"0": 18,
"0": 17,
"1": 1
},
{
"0": 22,
"1": 3
"1": 4
}
],
"0/29/1": [
49, 65, 63, 62, 60, 55, 54, 52, 51, 50, 48, 43, 40, 31, 30, 29, 3, 42, 45,
53
49, 70, 65, 63, 62, 60, 55, 54, 52, 51, 50, 48, 40, 31, 29, 42, 47, 53
],
"0/29/2": [41],
"0/29/3": [1],
@@ -288,13 +283,6 @@
"0/29/65528": [],
"0/29/65529": [],
"0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/3/0": 0,
"0/3/1": 2,
"0/3/65532": 0,
"0/3/65533": 6,
"0/3/65528": [],
"0/3/65529": [0, 64],
"0/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"0/42/0": [],
"0/42/1": true,
"0/42/2": 0,
@@ -304,12 +292,17 @@
"0/42/65528": [],
"0/42/65529": [0],
"0/42/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"0/45/0": 1,
"0/45/65532": 1,
"0/45/65533": 2,
"0/45/65528": [],
"0/45/65529": [],
"0/45/65531": [0, 65532, 65533, 65528, 65529, 65531],
"0/47/0": 1,
"0/47/1": 0,
"0/47/2": "USB",
"0/47/5": 0,
"0/47/6": 0,
"0/47/31": [],
"0/47/65532": 1,
"0/47/65533": 3,
"0/47/65528": [],
"0/47/65529": [],
"0/47/65531": [0, 1, 2, 5, 6, 31, 65532, 65533, 65528, 65529, 65531],
"0/53/0": null,
"0/53/1": null,
"0/53/2": null,
@@ -383,144 +376,96 @@
39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
57, 58, 59, 60, 61, 62, 65532, 65533, 65528, 65529, 65531
],
"1/3/0": 0,
"1/3/1": 2,
"1/3/65532": 0,
"1/3/65533": 6,
"1/3/65528": [],
"1/3/65529": [0],
"1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"1/29/0": [
{
"0": 769,
"1": 4
"0": 10,
"1": 3
},
{
"0": 17,
"1": 1
}
],
"1/29/1": [29, 3, 4, 513, 516],
"1/29/2": [3],
"1/29/1": [3, 29, 47, 257],
"1/29/2": [],
"1/29/3": [],
"1/29/65532": 0,
"1/29/65533": 3,
"1/29/65528": [],
"1/29/65529": [],
"1/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531],
"1/3/0": 0,
"1/3/1": 2,
"1/3/65532": 0,
"1/3/65533": 6,
"1/3/65528": [],
"1/3/65529": [0, 64],
"1/3/65531": [0, 1, 65532, 65533, 65528, 65529, 65531],
"1/4/0": 128,
"1/4/65532": 1,
"1/4/65533": 4,
"1/4/65528": [0, 1, 2, 3],
"1/4/65529": [0, 1, 2, 3, 4, 5],
"1/4/65531": [0, 65532, 65533, 65528, 65529, 65531],
"1/513/0": 1800,
"1/513/1": 500,
"1/513/3": 700,
"1/513/4": 3000,
"1/513/5": 1600,
"1/513/6": 3200,
"1/513/7": 0,
"1/513/8": 25,
"1/513/16": 0,
"1/513/17": 2600,
"1/513/18": 2000,
"1/513/21": 700,
"1/513/22": 3000,
"1/513/23": 1600,
"1/513/24": 3200,
"1/513/25": 25,
"1/513/26": 0,
"1/513/27": 4,
"1/513/28": 1,
"1/513/30": 4,
"1/513/35": 0,
"1/513/36": 0,
"1/513/37": 0,
"1/513/41": 1,
"1/513/48": 0,
"1/513/49": 150,
"1/513/50": 1761951600,
"1/513/72": [
{
"0": 1,
"1": 1,
"2": 1
},
{
"0": 2,
"1": 1,
"2": 1
},
{
"0": 3,
"1": 1,
"2": 2
},
{
"0": 4,
"1": 1,
"2": 2
},
{
"0": 5,
"1": 1,
"2": 2
},
{
"0": 254,
"1": 1,
"2": 2
}
"1/47/0": 1,
"1/47/1": 1,
"1/47/2": "Battery",
"1/47/14": 0,
"1/47/15": false,
"1/47/16": 0,
"1/47/19": "",
"1/47/25": 1,
"1/47/31": [],
"1/47/65532": 10,
"1/47/65533": 3,
"1/47/65528": [],
"1/47/65529": [],
"1/47/65531": [
0, 1, 2, 14, 15, 16, 19, 25, 31, 65532, 65533, 65528, 65529, 65531
],
"1/513/73": [
{
"0": 4,
"1": 1,
"2": 2
},
{
"0": 3,
"1": 1,
"2": 2
}
"1/257/0": 1,
"1/257/1": 0,
"1/257/2": true,
"1/257/3": 1,
"1/257/4": 10,
"1/257/5": 10,
"1/257/17": 10,
"1/257/18": 10,
"1/257/19": 10,
"1/257/20": 10,
"1/257/21": 10,
"1/257/22": 10,
"1/257/23": 8,
"1/257/24": 6,
"1/257/25": 20,
"1/257/26": 10,
"1/257/27": 1,
"1/257/28": 5,
"1/257/33": "en",
"1/257/35": 60,
"1/257/36": 0,
"1/257/37": 0,
"1/257/38": 65526,
"1/257/41": false,
"1/257/43": false,
"1/257/48": 3,
"1/257/49": 10,
"1/257/51": false,
"1/257/128": null,
"1/257/129": null,
"1/257/130": "JE4bj1uR9Kt/VH7lYJwxbw==",
"1/257/131": ["AQA="],
"1/257/132": null,
"1/257/133": ["AQA="],
"1/257/134": 0,
"1/257/135": 10,
"1/257/136": 10,
"1/257/65532": 32179,
"1/257/65533": 8,
"1/257/65528": [12, 15, 18, 28, 35, 37],
"1/257/65529": [
0, 1, 3, 11, 12, 13, 14, 15, 16, 17, 18, 19, 26, 27, 29, 34, 36, 38, 39,
40, 41
],
"1/513/74": 5,
"1/513/78": null,
"1/513/80": [
{
"0": "AQ==",
"1": 1,
"3": 2500,
"4": 2100,
"5": true
},
{
"0": "Ag==",
"1": 2,
"3": 2600,
"4": 2000,
"5": true
}
],
"1/513/82": 0,
"1/513/83": 5,
"1/513/84": [],
"1/513/85": null,
"1/513/86": null,
"1/513/65532": 419,
"1/513/65533": 9,
"1/513/65528": [2, 253],
"1/513/65529": [0, 6, 7, 8, 254],
"1/513/65531": [
0, 1, 3, 4, 5, 6, 7, 8, 16, 17, 18, 21, 22, 23, 24, 25, 26, 27, 28, 30,
35, 36, 37, 41, 48, 49, 50, 72, 73, 74, 78, 80, 82, 83, 84, 85, 86, 65532,
65533, 65528, 65529, 65531
],
"1/516/0": 0,
"1/516/1": 0,
"1/516/65532": 0,
"1/516/65533": 2,
"1/516/65528": [],
"1/516/65529": [],
"1/516/65531": [0, 1, 65532, 65533, 65528, 65529, 65531]
"1/257/65531": [
0, 1, 2, 3, 4, 5, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 33, 35,
36, 37, 38, 41, 43, 48, 49, 51, 128, 129, 130, 131, 132, 133, 134, 135,
136, 65532, 65533, 65528, 65529, 65531
]
},
"attribute_subscriptions": []
}

View File

@@ -489,6 +489,104 @@
'state': 'on',
})
# ---
# name: test_binary_sensors[mock_lock][binary_sensor.mock_lock_battery-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': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.mock_lock_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY: 'battery'>,
'original_icon': None,
'original_name': 'Battery',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-BatteryChargeLevel-47-14',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[mock_lock][binary_sensor.mock_lock_battery-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Mock Lock Battery',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_lock_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[mock_lock][binary_sensor.mock_lock_door-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': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.mock_lock_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Door',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-LockDoorStateSensor-257-3',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[mock_lock][binary_sensor.mock_lock_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'Mock Lock Door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_lock_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[occupancy_sensor][binary_sensor.mock_occupancy_sensor_occupancy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -2241,7 +2241,7 @@
'state': 'unknown',
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-entry]
# name: test_buttons[mock_lock][button.mock_lock_identify-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -2254,7 +2254,7 @@
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.mock_thermostat_identify_0',
'entity_id': 'button.mock_lock_identify',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -2266,73 +2266,24 @@
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (0)',
'original_name': 'Identify',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-0-IdentifyButton-3-1',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_0-state]
# name: test_buttons[mock_lock][button.mock_lock_identify-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Mock Thermostat Identify (0)',
'friendly_name': 'Mock Lock Identify',
}),
'context': <ANY>,
'entity_id': 'button.mock_thermostat_identify_0',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-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': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.mock_thermostat_identify_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
'original_icon': None,
'original_name': 'Identify (1)',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-IdentifyButton-3-1',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[mock_thermostat][button.mock_thermostat_identify_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'identify',
'friendly_name': 'Mock Thermostat Identify (1)',
}),
'context': <ANY>,
'entity_id': 'button.mock_thermostat_identify_1',
'entity_id': 'button.mock_lock_identify',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -325,77 +325,6 @@
'state': 'cool',
})
# ---
# name: test_climates[mock_thermostat][climate.mock_thermostat-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 32.0,
'min_temp': 7.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.mock_thermostat',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 387>,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0',
'unit_of_measurement': None,
})
# ---
# name: test_climates[mock_thermostat][climate.mock_thermostat-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.0,
'friendly_name': 'Mock Thermostat',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 32.0,
'min_temp': 7.0,
'supported_features': <ClimateEntityFeature: 387>,
'target_temp_high': 26.0,
'target_temp_low': 20.0,
'temperature': None,
}),
'context': <ANY>,
'entity_id': 'climate.mock_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat_cool',
})
# ---
# name: test_climates[room_airconditioner][climate.room_airconditioner-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -149,3 +149,53 @@
'state': 'locked',
})
# ---
# name: test_locks[mock_lock][lock.mock_lock-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': 'lock',
'entity_category': None,
'entity_id': 'lock.mock_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <LockEntityFeature: 1>,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-MatterLock-257-0',
'unit_of_measurement': None,
})
# ---
# name: test_locks[mock_lock][lock.mock_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'changed_by': 'Unknown',
'friendly_name': 'Mock Lock',
'supported_features': <LockEntityFeature: 1>,
}),
'context': <ANY>,
'entity_id': 'lock.mock_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'locked',
})
# ---

View File

@@ -1731,6 +1731,179 @@
'state': '30',
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_auto_relock_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 65534,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mock_lock_auto_relock_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Auto-relock time',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'auto_relock_timer',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-AutoRelockTimer-257-35',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_auto_relock_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Lock Auto-relock time',
'max': 65534,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.mock_lock_auto_relock_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '60',
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_user_code_temporary_disable_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 255,
'min': 1,
'mode': <NumberMode.BOX: 'box'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mock_lock_user_code_temporary_disable_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'User code temporary disable time',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'user_code_temporary_disable_time',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-DoorLockUserCodeTemporaryDisableTime-257-49',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_user_code_temporary_disable_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Lock User code temporary disable time',
'max': 255,
'min': 1,
'mode': <NumberMode.BOX: 'box'>,
'step': 1,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.mock_lock_user_code_temporary_disable_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_wrong_code_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 255,
'min': 1,
'mode': <NumberMode.BOX: 'box'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mock_lock_wrong_code_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Wrong code limit',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wrong_code_entry_limit',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-DoorLockWrongCodeEntryLimit-257-48',
'unit_of_measurement': None,
})
# ---
# name: test_numbers[mock_lock][number.mock_lock_wrong_code_limit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Lock Wrong code limit',
'max': 255,
'min': 1,
'mode': <NumberMode.BOX: 'box'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.mock_lock_wrong_code_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3',
})
# ---
# name: test_numbers[mounted_dimmable_load_control_fixture][number.mock_mounted_dimmable_load_control_on_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -2257,15 +2257,17 @@
'state': '1000',
})
# ---
# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-entry]
# name: test_selects[mock_lock][select.mock_lock_sound_volume-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'Celsius',
'Fahrenheit',
'silent',
'low',
'medium',
'high',
]),
}),
'config_entry_id': <ANY>,
@@ -2275,7 +2277,7 @@
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.mock_thermostat_temperature_display_mode',
'entity_id': 'select.mock_lock_sound_volume',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -2287,31 +2289,33 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Temperature display mode',
'original_name': 'Sound volume',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'temperature_display_mode',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0',
'translation_key': 'door_lock_sound_volume',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-DoorLockSoundVolume-257-36',
'unit_of_measurement': None,
})
# ---
# name: test_selects[mock_thermostat][select.mock_thermostat_temperature_display_mode-state]
# name: test_selects[mock_lock][select.mock_lock_sound_volume-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Thermostat Temperature display mode',
'friendly_name': 'Mock Lock Sound volume',
'options': list([
'Celsius',
'Fahrenheit',
'silent',
'low',
'medium',
'high',
]),
}),
'context': <ANY>,
'entity_id': 'select.mock_thermostat_temperature_display_mode',
'entity_id': 'select.mock_lock_sound_volume',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Celsius',
'state': 'silent',
})
# ---
# name: test_selects[mounted_dimmable_load_control_fixture][select.mock_mounted_dimmable_load_control_power_on_behavior_on_startup-entry]

View File

@@ -7259,332 +7259,6 @@
'state': 'stopped',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-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': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_thermostat_heating_demand',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Heating demand',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'pi_heating_demand',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatPIHeatingDemand-513-8',
'unit_of_measurement': '%',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_heating_demand-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Thermostat Heating demand',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_heating_demand',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '25',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last change',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_timestamp',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSourceTimestamp-513-50',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'Mock Thermostat Last change',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2025-10-31T23:00:00+00:00',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Last change amount',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_amount',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatSetpointChangeAmount-513-49',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_amount-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Last change amount',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_amount',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.5',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Last change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_last_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock Thermostat Last change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_last_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_outdoor_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outdoor temperature',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outdoor_temperature',
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatOutdoorTemperature-513-1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_outdoor_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Outdoor temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_outdoor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mock_thermostat_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-ThermostatLocalTemperature-513-0',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[mock_thermostat][sensor.mock_thermostat_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Thermostat Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_thermostat_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.0',
})
# ---
# name: test_sensors[multi_endpoint_light][sensor.inovelli_current_switch_position_config-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -682,6 +682,54 @@
'state': 'on',
})
# ---
# name: test_switches[mock_lock][switch.mock_lock_privacy_mode_button-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.mock_lock_privacy_mode_button',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Privacy mode button',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'privacy_mode_button',
'unique_id': '00000000000004D2-0000000000000097-MatterNodeDevice-1-DoorLockEnablePrivacyModeButton-257-43',
'unit_of_measurement': None,
})
# ---
# name: test_switches[mock_lock][switch.mock_lock_privacy_mode_button-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Lock Privacy mode button',
}),
'context': <ANY>,
'entity_id': 'switch.mock_lock_privacy_mode_button',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -697,91 +697,3 @@ async def test_vacuum_operational_error_sensor(
state = hass.states.get("sensor.mock_vacuum_operational_error")
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_source(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeSource sensor."""
# Thermostat Cluster / SetpointChangeSource attribute (1/513/48)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "manual"
assert state.attributes["options"] == ["manual", "schedule", "external"]
# Test schedule source
set_node_attribute(matter_node, 1, 513, 48, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "schedule"
# Test external source
set_node_attribute(matter_node, 1, 513, 48, 2)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_source")
assert state
assert state.state == "external"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_timestamp(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeSourceTimestamp sensor."""
# Thermostat Cluster / SetpointChangeSourceTimestamp attribute (1/513/50)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "2025-10-31T23:00:00+00:00"
# Update to a new timestamp (2024-11-15 12:00:00 UTC)
set_node_attribute(matter_node, 1, 513, 50, 1731672000)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "2024-11-15T12:00:00+00:00"
# Test zero value (should be None/unknown)
set_node_attribute(matter_node, 1, 513, 50, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change")
assert state
assert state.state == "unknown"
@pytest.mark.parametrize("node_fixture", ["mock_thermostat"])
async def test_thermostat_setpoint_change_amount(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test Thermostat SetpointChangeAmount sensor."""
# Thermostat Cluster / SetpointChangeAmount attribute (1/513/49)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "1.5"
# Update to 2.0°C (200 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, 200)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "2.0"
# Update to -0.5°C (-50 in Matter units)
set_node_attribute(matter_node, 1, 513, 49, -50)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("sensor.mock_thermostat_last_change_amount")
assert state
assert state.state == "-0.5"

View File

@@ -52,7 +52,8 @@ def mock_client():
client = client.return_value
client.async_login.return_value = True
client.status = ChargerStatus.CHARGING
client.power = ChargerPower(0, 0, 0, 0)
client.power = ChargerPower(0, 0, 0)
client.available = True
client.target_soc = 50
client.target_time = (8, 0)

View File

@@ -47,59 +47,6 @@
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_ct_current-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'CT current',
'platform': 'ohme',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ct_current',
'unique_id': 'chargerid_ct_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_ct_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Ohme Home Pro CT current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -5,6 +5,7 @@
dict({
'end': '2024-12-30T04:30:39+00:00',
'energy': 2.042,
'power': 4.0,
'start': '2024-12-30T04:00:00+00:00',
}),
]),

View File

@@ -1 +1,18 @@
"""Tests for the Sunricher Sunricher DALI integration."""
from collections.abc import Callable
from unittest.mock import MagicMock
from PySrDaliGateway import CallbackEventType
def find_device_listener(
device: MagicMock, event_type: CallbackEventType
) -> Callable[..., None]:
"""Find the registered listener callback for a specific device and event type."""
for call in device.register_listener.call_args_list:
if call[0][0] == event_type:
return call[0][1]
raise AssertionError(
f"Listener for event type {event_type} not found on device {device.dev_id}"
)

View File

@@ -56,6 +56,7 @@ def _create_mock_device(
device.turn_on = MagicMock()
device.turn_off = MagicMock()
device.read_status = MagicMock()
device.register_listener = MagicMock(return_value=lambda: None)
return device

View File

@@ -3,6 +3,7 @@
from typing import Any
from unittest.mock import MagicMock, patch
from PySrDaliGateway import CallbackEventType
import pytest
from homeassistant.components.light import (
@@ -15,6 +16,8 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import find_device_listener
from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform
TEST_DIMMER_ENTITY_ID = "light.dimmer_0000_02"
@@ -24,13 +27,20 @@ TEST_HS_DEVICE_ID = "01030000046A242121110E"
TEST_RGBW_DEVICE_ID = "01040000056A242121110E"
def _dispatch_status(
gateway: MagicMock, device_id: str, status: dict[str, Any]
def _trigger_light_status_callback(
device: MagicMock, device_id: str, status: dict[str, Any]
) -> None:
"""Invoke the status callback registered on the gateway mock."""
callback = gateway.on_light_status
assert callable(callback)
callback(device_id, status)
"""Trigger the light status callbacks registered on the device mock."""
callback = find_device_listener(device, CallbackEventType.LIGHT_STATUS)
callback(status)
def _trigger_availability_callback(
device: MagicMock, device_id: str, available: bool
) -> None:
"""Trigger the availability callbacks registered on the device mock."""
callback = find_device_listener(device, CallbackEventType.ONLINE_STATUS)
callback(available)
@pytest.fixture
@@ -133,26 +143,25 @@ async def test_turn_on_with_brightness(
)
async def test_dispatcher_connection(
async def test_callback_registration(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_devices: list[MagicMock],
mock_gateway: MagicMock,
) -> None:
"""Test that dispatcher signals are properly connected."""
entity_entry = er.async_get(hass).async_get(TEST_DIMMER_ENTITY_ID)
assert entity_entry is not None
state = hass.states.get(TEST_DIMMER_ENTITY_ID)
assert state is not None
"""Test that callbacks are properly registered and triggered."""
state_before = hass.states.get(TEST_DIMMER_ENTITY_ID)
assert state_before is not None
status_update: dict[str, Any] = {"is_on": True, "brightness": 128}
_dispatch_status(mock_gateway, TEST_DIMMER_DEVICE_ID, status_update)
_trigger_light_status_callback(
mock_devices[0], TEST_DIMMER_DEVICE_ID, status_update
)
await hass.async_block_till_done()
state_after = hass.states.get(TEST_DIMMER_ENTITY_ID)
assert state_after is not None
assert state_after.state == "on"
assert state_after.attributes.get("brightness") == 128
@pytest.mark.parametrize(
@@ -168,10 +177,28 @@ async def test_dispatcher_connection(
async def test_status_updates(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_gateway: MagicMock,
mock_devices: list[MagicMock],
device_id: str,
status_update: dict[str, Any],
) -> None:
"""Test various status updates for different device types."""
_dispatch_status(mock_gateway, device_id, status_update)
device = next(d for d in mock_devices if d.dev_id == device_id)
_trigger_light_status_callback(device, device_id, status_update)
await hass.async_block_till_done()
async def test_device_availability(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_devices: list[MagicMock],
) -> None:
"""Test device availability changes."""
_trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, False)
await hass.async_block_till_done()
assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID))
assert state.state == "unavailable"
_trigger_availability_callback(mock_devices[0], TEST_DIMMER_DEVICE_ID, True)
await hass.async_block_till_done()
assert (state := hass.states.get(TEST_DIMMER_ENTITY_ID))
assert state.state != "unavailable"

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import pathlib
from typing import Any
from unittest.mock import patch
@@ -12,281 +13,11 @@ from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
DEVICE_MOCKS = [
"bzyd_45idzfufidgee7ir", # https://github.com/orgs/home-assistant/discussions/717
"bzyd_ssimhf6r8kgwepfb", # https://github.com/orgs/home-assistant/discussions/718
"cjkg_hmabvy81", # https://github.com/orgs/home-assistant/discussions/1297
"cjkg_uenof8jd", # https://github.com/home-assistant/core/issues/151825
"ckmkzq_1yyqfw4djv9eii3q", # https://github.com/home-assistant/core/issues/150856
"cl_3r8gc33pnqsxfe1g", # https://github.com/tuya/tuya-home-assistant/issues/754
"cl_669wsr2w4cvinbh4", # https://github.com/home-assistant/core/issues/150856
"cl_cpbo62rn", # https://github.com/orgs/home-assistant/discussions/539
"cl_ebt12ypvexnixvtf", # https://github.com/tuya/tuya-home-assistant/issues/754
"cl_g1cp07dsqnbdbbki", # https://github.com/home-assistant/core/issues/139966
"cl_lfkr93x0ukp5gaia", # https://github.com/home-assistant/core/issues/152826
"cl_n3xgr5pdmpinictg", # https://github.com/home-assistant/core/issues/153537
"cl_qqdxfdht", # https://github.com/orgs/home-assistant/discussions/539
"cl_rD7uqAAgQOpSA2Rx", # https://github.com/home-assistant/core/issues/139966
"cl_zah67ekd", # https://github.com/home-assistant/core/issues/71242
"clkg_nhyj64w2", # https://github.com/home-assistant/core/issues/136055
"clkg_wltqkykhni0papzj", # https://github.com/home-assistant/core/issues/151635
"clkg_xqvhthwkbmp3aghs", # https://github.com/home-assistant/core/issues/139966
"co2bj_yakol79dibtswovc", # https://github.com/home-assistant/core/issues/151784
"co2bj_yrr3eiyiacm31ski", # https://github.com/orgs/home-assistant/discussions/842
"cobj_hcdy5zrq3ikzthws", # https://github.com/orgs/home-assistant/discussions/482
"cs_b9oyi2yofflroq1g", # https://github.com/home-assistant/core/issues/139966
"cs_eguoms25tkxtf5u8", # https://github.com/home-assistant/core/issues/152361
"cs_ipmyy4nigpqcnd8q", # https://github.com/home-assistant/core/pull/148726
"cs_ka2wfrdoogpvgzfi", # https://github.com/home-assistant/core/issues/119865
"cs_qhxmvae667uap4zh", # https://github.com/home-assistant/core/issues/141278
"cs_vmxuxszzjwp5smli", # https://github.com/home-assistant/core/issues/119865
"cs_u0wirz487erb0eka", # https://github.com/home-assistant/core/issues/155364
"cs_zibqa9dutqyaxym2", # https://github.com/home-assistant/core/pull/125098
"cwjwq_agwu93lr", # https://github.com/orgs/home-assistant/discussions/79
"cwwsq_lxfvx41gqdotrkgi", # https://github.com/orgs/home-assistant/discussions/730
"cwwsq_wfkzyy0evslzsmoi", # https://github.com/home-assistant/core/issues/144745
"cwysj_akln8rb04cav403q", # https://github.com/home-assistant/core/pull/146599
"cwysj_z3rpyvznfcch99aa", # https://github.com/home-assistant/core/pull/146599
"cz_0fHWRe8ULjtmnBNd", # https://github.com/home-assistant/core/issues/139966
"cz_0g1fmqh6d5io7lcn", # https://github.com/home-assistant/core/issues/149704
"cz_2iepauebcvo74ujc", # https://github.com/home-assistant/core/issues/141278
"cz_2jxesipczks0kdct", # https://github.com/home-assistant/core/issues/147149
"cz_37mnhia3pojleqfh", # https://github.com/home-assistant/core/issues/146164
"cz_39sy2g68gsjwo2xv", # https://github.com/home-assistant/core/issues/141278
"cz_4mbgrfotyNzMxDAv", # https://github.com/home-assistant/core/issues/152295
"cz_6fa7odsufen374x2", # https://github.com/home-assistant/core/issues/150029
"cz_79a7z01v3n35kytb", # https://github.com/orgs/home-assistant/discussions/221
"cz_9ivirni8wemum6cw", # https://github.com/home-assistant/core/issues/139735
"cz_AiHXxAyyn7eAkLQY", # https://github.com/home-assistant/core/issues/150662
"cz_CHLZe9HQ6QIXujVN", # https://github.com/home-assistant/core/issues/149233
"cz_HBRBzv1UVBVfF6SL", # https://github.com/tuya/tuya-home-assistant/issues/754
"cz_IGzCi97RpN2Lf9cu", # https://github.com/home-assistant/core/issues/139966
"cz_PGEkBctAbtzKOZng", # https://github.com/home-assistant/core/issues/139966
"cz_anwgf2xugjxpkfxb", # https://github.com/orgs/home-assistant/discussions/539
"cz_cuhokdii7ojyw8k2", # https://github.com/home-assistant/core/issues/149704
"cz_dhto3y4uachr1wll", # https://github.com/orgs/home-assistant/discussions/169
"cz_dntgh2ngvshfxpsz", # https://github.com/home-assistant/core/issues/149704
"cz_fencxse0bnut96ig", # https://github.com/home-assistant/core/issues/63978
"cz_gbtxrqfy9xcsakyp", # https://github.com/home-assistant/core/issues/141278
"cz_gjnozsaz", # https://github.com/orgs/home-assistant/discussions/482
"cz_hA2GsgMfTQFTz9JL", # https://github.com/home-assistant/core/issues/148347
"cz_hj0a5c7ckzzexu8l", # https://github.com/home-assistant/core/issues/149704
"cz_ik9sbig3mthx9hjz", # https://github.com/home-assistant/core/issues/141278
"cz_ipabufmlmodje1ws", # https://github.com/home-assistant/core/issues/63978
"cz_iqhidxhhmgxk5eja", # https://github.com/home-assistant/core/issues/149233
"cz_jnbbxsb84gvvyfg5", # https://github.com/tuya/tuya-home-assistant/issues/754
"cz_jti3ce2hzvsposgj", # https://github.com/home-assistant/core/issues/152295
"cz_mQUhiTg9kwydBFBd", # https://github.com/home-assistant/core/issues/139966
"cz_n8iVBAPLFKAAAszH", # https://github.com/home-assistant/core/issues/146164
"cz_nkb0fmtlfyqosnvk", # https://github.com/orgs/home-assistant/discussions/482
"cz_nx8rv6jpe1tsnffk", # https://github.com/home-assistant/core/issues/148347
"cz_piuensvr", # https://github.com/home-assistant/core/issues/139966
"cz_qm0iq4nqnrlzh4qc", # https://github.com/home-assistant/core/issues/141278
"cz_qxJSyTLEtX5WrzA9", # https://github.com/home-assistant/core/issues/139966
"cz_raceucn29wk2yawe", # https://github.com/tuya/tuya-home-assistant/issues/754
"cz_sb6bwb1n8ma2c5q4", # https://github.com/home-assistant/core/issues/141278
"cz_t0a4hwsf8anfsadp", # https://github.com/home-assistant/core/issues/149704
"cz_tf6qp8t3hl9h7m94", # https://github.com/home-assistant/core/issues/143209
"cz_tkn2s79mzedk6pwr", # https://github.com/home-assistant/core/issues/146164
"cz_vrbpx6h7fsi5mujb", # https://github.com/home-assistant/core/pull/149234
"cz_vxqn72kwtosoy4d3", # https://github.com/home-assistant/core/issues/141278
"cz_w0qqde0g", # https://github.com/orgs/home-assistant/discussions/482
"cz_wifvoilfrqeo6hvu", # https://github.com/home-assistant/core/issues/146164
"cz_wrz6vzch8htux2zp", # https://github.com/home-assistant/core/issues/141278
"cz_y4jnobxh", # https://github.com/orgs/home-assistant/discussions/482
"cz_yncyws7tu1q4cpsz", # https://github.com/home-assistant/core/issues/150662
"cz_z6pht25s3p0gs26q", # https://github.com/home-assistant/core/issues/63978
"dc_l3bpgg8ibsagon4x", # https://github.com/home-assistant/core/issues/149704
"dd_gaobbrxqiblcng2p", # https://github.com/home-assistant/core/issues/149233
"dj_0gyaslysqfp4gfis", # https://github.com/home-assistant/core/issues/149895
"dj_8szt7whdvwpmxglk", # https://github.com/home-assistant/core/issues/149704
"dj_8ugheslg", # https://github.com/home-assistant/core/issues/150856
"dj_8y0aquaa8v6tho8w", # https://github.com/home-assistant/core/issues/149704
"dj_AqHUMdcbYzIq1Of4", # https://github.com/orgs/home-assistant/discussions/539
"dj_amx1bgdrfab6jngb", # https://github.com/orgs/home-assistant/discussions/482
"dj_bSXSSFArVKtc4DyC", # https://github.com/orgs/home-assistant/discussions/539
"dj_baf9tt9lb8t5uc7z", # https://github.com/home-assistant/core/issues/149704
"dj_c3nsqogqovapdpfj", # https://github.com/home-assistant/core/issues/146164
"dj_d4g0fbsoaal841o6", # https://github.com/home-assistant/core/issues/149704
"dj_dbou1ap4", # https://github.com/orgs/home-assistant/discussions/482
"dj_djnozmdyqyriow8z", # https://github.com/home-assistant/core/issues/149704
"dj_ekwolitfjhxn55js", # https://github.com/home-assistant/core/issues/149704
"dj_fuupmcr2mb1odkja", # https://github.com/home-assistant/core/issues/149704
"dj_h4aX2JkHZNByQ4AV", # https://github.com/home-assistant/core/issues/150662
"dj_hp6orhaqm6as3jnv", # https://github.com/home-assistant/core/issues/149704
"dj_hpc8ddyfv85haxa7", # https://github.com/home-assistant/core/issues/149704
"dj_iayz2jmtlipjnxj7", # https://github.com/home-assistant/core/issues/149704
"dj_idnfq7xbx8qewyoa", # https://github.com/home-assistant/core/issues/149704
"dj_ilddqqih3tucdk68", # https://github.com/home-assistant/core/issues/149704
"dj_j1bgp31cffutizub", # https://github.com/home-assistant/core/issues/149704
"dj_kgaob37tz2muf3mi", # https://github.com/home-assistant/core/issues/150856
"dj_lmnt3uyltk1xffrt", # https://github.com/home-assistant/core/issues/149704
"dj_mki13ie507rlry4r", # https://github.com/home-assistant/core/pull/126242
"dj_nbumqpv8vz61enji", # https://github.com/home-assistant/core/issues/149704
"dj_nlxvjzy1hoeiqsg6", # https://github.com/home-assistant/core/issues/149704
"dj_oe0cpnjg", # https://github.com/home-assistant/core/issues/149704
"dj_qoqolwtqzfuhgghq", # https://github.com/home-assistant/core/issues/149233
"dj_riwp3k79", # https://github.com/home-assistant/core/issues/149704
"dj_tgewj70aowigv8fz", # https://github.com/orgs/home-assistant/discussions/539
"dj_tmsloaroqavbucgn", # https://github.com/home-assistant/core/issues/149704
"dj_ufq2xwuzd4nb0qdr", # https://github.com/home-assistant/core/issues/149704
"dj_vqwcnabamzrc2kab", # https://github.com/home-assistant/core/issues/149704
"dj_xdvitmhhmgefaeuq", # https://github.com/home-assistant/core/issues/146164
"dj_xokdfs6kh5ednakk", # https://github.com/home-assistant/core/issues/149704
"dj_zakhnlpdiu0ycdxn", # https://github.com/home-assistant/core/issues/149704
"dj_zav1pa32pyxray78", # https://github.com/home-assistant/core/issues/149704
"dj_zputiamzanuk6yky", # https://github.com/home-assistant/core/issues/149704
"dlq_0tnvg2xaisqdadcf", # https://github.com/home-assistant/core/issues/102769
"dlq_cnpkf4xdmd9v49iq", # https://github.com/home-assistant/core/pull/149320
"dlq_dikb3dp6", # https://github.com/home-assistant/core/pull/151601
"dlq_jdj6ccklup7btq3a", # https://github.com/home-assistant/core/issues/143209
"dlq_kxdr6su0c55p7bbo", # https://github.com/home-assistant/core/issues/143499
"dlq_r9kg2g1uhhyicycb", # https://github.com/home-assistant/core/issues/149650
"dlq_z3jngbyubvwgfrcv", # https://github.com/home-assistant/core/issues/150293
"dr_pjvxl1wsyqxivsaf", # https://github.com/home-assistant/core/issues/84869
"fs_g0ewlb1vmwqljzji", # https://github.com/home-assistant/core/issues/141231
"fs_ibytpo6fpnugft1c", # https://github.com/home-assistant/core/issues/135541
"fsd_9ecs16c53uqskxw6", # https://github.com/home-assistant/core/issues/149233
"gyd_lgekqfxdabipm3tn", # https://github.com/home-assistant/core/issues/133173
"hps_2aaelwxk", # https://github.com/home-assistant/core/issues/149704
"hps_wqashyqo", # https://github.com/home-assistant/core/issues/146180
"hwsb_ircs2n82vgrozoew", # https://github.com/home-assistant/core/issues/149233
"jsq_r492ifwk6f2ssptb", # https://github.com/home-assistant/core/issues/151488
"jtmspro_xqeob8h6", # https://github.com/orgs/home-assistant/discussions/517
"kg_4nqs33emdwJxpQ8O", # https://github.com/orgs/home-assistant/discussions/539
"kg_5ftkaulg", # https://github.com/orgs/home-assistant/discussions/539
"kg_gbm9ata1zrzaez4a", # https://github.com/home-assistant/core/issues/148347
"kj_CAjWAxBUZt7QZHfz", # https://github.com/home-assistant/core/issues/146023
"kj_fsxtzzhujkrak2oy", # https://github.com/orgs/home-assistant/discussions/439
"kj_s4uzibibgzdxzowo", # https://github.com/home-assistant/core/issues/150246
"kj_yrzylxax1qspdgpp", # https://github.com/orgs/home-assistant/discussions/61
"ks_j9fa8ahzac8uvlfl", # https://github.com/orgs/home-assistant/discussions/329
"kt_5wnlzekkstwcdsvm", # https://github.com/home-assistant/core/pull/148646
"kt_ibmmirhhq62mmf1g", # https://github.com/home-assistant/core/pull/150077
"kt_vdadlnmsorlhw4td", # https://github.com/home-assistant/core/pull/149635
"ldcg_9kbbfeho", # https://github.com/orgs/home-assistant/discussions/482
"mal_gyitctrjj1kefxp2", # Alarm Host support
"mc_oSQljE9YDqwCwTUA", # https://github.com/home-assistant/core/issues/149233
"mcs_6ywsnauy", # https://github.com/orgs/home-assistant/discussions/482
"mcs_7jIGJAymiH8OsFFb", # https://github.com/home-assistant/core/issues/108301
"mcs_8yhypbo7", # https://github.com/orgs/home-assistant/discussions/482
"mcs_hx5ztlztij4yxxvg", # https://github.com/home-assistant/core/issues/148347
"mcs_oxslv1c9", # https://github.com/home-assistant/core/issues/139966
"mcs_qxu3flpqjsc1kqu3", # https://github.com/home-assistant/core/issues/141278
"msp_3ddulzljdjjwkhoy", # https://github.com/orgs/home-assistant/discussions/262
"msp_mzuro9jgjs7uiryn", # https://github.com/home-assistant/core/pull/156048
"mzj_jlapoy5liocmtdvd", # https://github.com/home-assistant/core/issues/150662
"mzj_qavcakohisj5adyh", # https://github.com/home-assistant/core/issues/141278
"ntq_9mqdhwklpvnnvb7t", # https://github.com/orgs/home-assistant/discussions/517
"pc_t2afic7i3v1bwhfp", # https://github.com/home-assistant/core/issues/149704
"pc_trjopo1vdlt9q1tg", # https://github.com/home-assistant/core/issues/149704
"pc_tsbguim4trl6fa7g", # https://github.com/home-assistant/core/issues/146164
"pc_yku9wsimasckdt15", # https://github.com/orgs/home-assistant/discussions/482
"pir_3amxzozho9xp4mkh", # https://github.com/home-assistant/core/issues/149704
"pir_fcdjzz3s", # https://github.com/home-assistant/core/issues/149704
"pir_j5jgnjvdaczeb6dc", # https://github.com/orgs/home-assistant/discussions/582
"pir_wqz93nrdomectyoz", # https://github.com/home-assistant/core/issues/149704
"qccdz_7bvgooyjhiua1yyq", # https://github.com/home-assistant/core/issues/136207
"qn_5ls2jw49hpczwqng", # https://github.com/home-assistant/core/issues/149233
"qt_TtXKwTMwiPpURWLJ", # https://github.com/home-assistant/core/issues/139966
"qxj_fsea1lat3vuktbt6", # https://github.com/orgs/home-assistant/discussions/318
"qxj_is2indt9nlth6esa", # https://github.com/home-assistant/core/issues/136472
"qxj_xbwbniyt6bgws9ia", # https://github.com/orgs/home-assistant/discussions/823
"rqbj_4iqe2hsfyd86kwwc", # https://github.com/orgs/home-assistant/discussions/100
"rs_d7woucobqi8ncacf", # https://github.com/orgs/home-assistant/discussions/1021
"sd_i6hyjg3af7doaswm", # https://github.com/orgs/home-assistant/discussions/539
"sd_lr33znaodtyarrrz", # https://github.com/home-assistant/core/issues/141278
"sfkzq_1fcnd8xk", # https://github.com/orgs/home-assistant/discussions/539
"sfkzq_d4vpmigg", # https://github.com/home-assistant/core/issues/150662
"sfkzq_ed7frwissyqrejic", # https://github.com/home-assistant/core/pull/149236
"sfkzq_nxquc5lb", # https://github.com/home-assistant/core/issues/150662
"sfkzq_o6dagifntoafakst", # https://github.com/home-assistant/core/issues/148116
"sfkzq_rzklytdei8i8vo37", # https://github.com/home-assistant/core/issues/146164
"sgbj_DYgId0sz6zWlmmYu", # https://github.com/orgs/home-assistant/discussions/583
"sgbj_im2eqqhj72suwwko", # https://github.com/home-assistant/core/issues/151082
"sgbj_ulv4nnue7gqp0rjk", # https://github.com/home-assistant/core/issues/149704
"sj_rzeSU2h9uoklxEwq", # https://github.com/home-assistant/core/issues/150683
"sj_tgvtvdoc", # https://github.com/orgs/home-assistant/discussions/482
"sjz_ftbc8rp8ipksdfpv", # https://github.com/orgs/home-assistant/discussions/51
"sp_6bmk1remyscwyx6i", # https://github.com/orgs/home-assistant/discussions/842
"sp_csr2fqitalj5o0tq", # https://github.com/home-assistant/core/pull/156103
"sp_drezasavompxpcgm", # https://github.com/home-assistant/core/issues/149704
"sp_nzauwyj3mcnjnf35", # https://github.com/home-assistant/core/issues/141278
"sp_rjKXWRohlvOTyLBu", # https://github.com/home-assistant/core/issues/149704
"sp_rudejjigkywujjvs", # https://github.com/home-assistant/core/issues/146164
"sp_sdd5f5f2dl5wydjf", # https://github.com/home-assistant/core/issues/144087
"swtz_3rzngbyy", # https://github.com/orgs/home-assistant/discussions/688
"szjcy_u5xgcpcngk3pfxb4", # https://github.com/orgs/home-assistant/discussions/934
"tdq_1aegphq4yfd50e6b", # https://github.com/home-assistant/core/issues/143209
"tdq_9htyiowaf5rtdhrv", # https://github.com/home-assistant/core/issues/143209
"tdq_cq1p0nt0a4rixnex", # https://github.com/home-assistant/core/issues/146845
"tdq_nockvv2k39vbrxxk", # https://github.com/home-assistant/core/issues/145849
"tdq_p6sqiuesvhmhvv4f", # https://github.com/orgs/home-assistant/discussions/430
"tdq_pu8uhxhwcp3tgoz7", # https://github.com/home-assistant/core/issues/141278
"tdq_uoa3mayicscacseb", # https://github.com/home-assistant/core/issues/128911
"tdq_x3o8epevyeo3z3oa", # https://github.com/orgs/home-assistant/discussions/430
"tyndj_pyakuuoc", # https://github.com/home-assistant/core/issues/149704
"wfcon_b25mh8sxawsgndck", # https://github.com/home-assistant/core/issues/149704
"wfcon_lieerjyy6l4ykjor", # https://github.com/home-assistant/core/issues/136055
"wfcon_plp0gnfcacdeqk5o", # https://github.com/home-assistant/core/issues/139966
"wg2_2gowdgni", # https://github.com/home-assistant/core/issues/150856
"wg2_haclbl0qkqlf2qds", # https://github.com/orgs/home-assistant/discussions/517
"wg2_nwxr8qcu4seltoro", # https://github.com/orgs/home-assistant/discussions/430
"wg2_setmxeqgs63xwopm", # https://github.com/orgs/home-assistant/discussions/539
"wg2_tmwhss6ntjfc7prs", # https://github.com/home-assistant/core/issues/150662
"wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539
"wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513
"wk_IAYz2WK1th0cMLmL", # https://github.com/home-assistant/core/issues/150077
"wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263
"wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551
"wk_cpmgn2cf", # https://github.com/orgs/home-assistant/discussions/684
"wk_fi6dne5tu4t1nm6j", # https://github.com/orgs/home-assistant/discussions/243
"wk_gc1bxoq2hafxpa35", # https://github.com/home-assistant/core/issues/145551
"wk_gogb05wrtredz3bs", # https://github.com/home-assistant/core/issues/136337
"wk_t94pit6zjbask9qo", # https://github.com/home-assistant/core/pull/156781
"wk_tfbhw0mg", # https://github.com/home-assistant/core/issues/152282
"wk_y5obtqhuztqsf2mj", # https://github.com/home-assistant/core/issues/139735
"wkcz_gc4b1mdw7kebtuyz", # https://github.com/home-assistant/core/issues/135617
"wkf_9xfjixap", # https://github.com/home-assistant/core/issues/139966
"wkf_p3dbf6qs", # https://github.com/home-assistant/core/issues/139966
"wnykq_kzwdw5bpxlbs9h9g", # https://github.com/orgs/home-assistant/discussions/842
"wnykq_npbbca46yiug8ysk", # https://github.com/orgs/home-assistant/discussions/539
"wnykq_om518smspsaltzdi", # https://github.com/home-assistant/core/issues/150662
"wnykq_rqhxdyusjrwxyff6", # https://github.com/home-assistant/core/issues/133173
"wsdcg_g2y6z3p3ja2qhyav", # https://github.com/home-assistant/core/issues/102769
"wsdcg_iq4ygaai", # https://github.com/orgs/home-assistant/discussions/482
"wsdcg_iv7hudlj", # https://github.com/home-assistant/core/issues/141278
"wsdcg_krlcihrpzpc8olw9", # https://github.com/orgs/home-assistant/discussions/517
"wsdcg_lf36y5nwb8jkxwgg", # https://github.com/orgs/home-assistant/discussions/539
"wsdcg_qrztc3ev", # https://github.com/home-assistant/core/issues/139966
"wsdcg_vtA4pDd6PLUZzXgZ", # https://github.com/orgs/home-assistant/discussions/482
"wsdcg_xr3htd96", # https://github.com/orgs/home-assistant/discussions/482
"wsdcg_yqiqbaldtr0i7mru", # https://github.com/home-assistant/core/issues/136223
"wxkg_ja5osu5g", # https://github.com/orgs/home-assistant/discussions/482
"wxkg_l8yaz4um5b3pwyvf", # https://github.com/home-assistant/core/issues/93975
"wxnbq_5l1ht8jygsyr1wn1", # https://github.com/orgs/home-assistant/discussions/685
"xdd_shx9mmadyyeaq88t", # https://github.com/home-assistant/core/issues/151141
"xnyjcn_pb0tc75khaik8qbg", # https://github.com/home-assistant/core/pull/149237
"ydkt_jevroj5aguwdbs2e", # https://github.com/orgs/home-assistant/discussions/288
"ygsb_l6ax0u6jwbz82atk", # https://github.com/home-assistant/core/issues/146319
"ykq_bngwdjsr", # https://github.com/orgs/home-assistant/discussions/482
"ywbj_arywmw6h6vesoz5t", # https://github.com/home-assistant/core/issues/146164
"ywbj_cjlutkuuvxnie17o", # https://github.com/home-assistant/core/issues/146164
"ywbj_gf9dejhmzffgdyfj", # https://github.com/home-assistant/core/issues/149704
"ywbj_kscbebaf3s1eogvt", # https://github.com/home-assistant/core/issues/141278
"ywbj_rccxox8p", # https://github.com/orgs/home-assistant/discussions/625
"ywcgq_h8lvyoahr6s6aybf", # https://github.com/home-assistant/core/issues/145932
"ywcgq_wtzwyhkev3b4ubns", # https://github.com/home-assistant/core/issues/103818
"zjq_nkkl7uzv", # https://github.com/orgs/home-assistant/discussions/482
"zndb_4ggkyflayu1h1ho9", # https://github.com/home-assistant/core/pull/149317
"zndb_v5jlnn5hwyffkhp3", # https://github.com/home-assistant/core/issues/143209
"zndb_ze8faryrxr0glqnn", # https://github.com/home-assistant/core/issues/138372
"znnbq_0kllybtbzftaee7y", # https://github.com/orgs/home-assistant/discussions/685
"znnbq_6b3pbbuqbfabhfiq", # https://github.com/orgs/home-assistant/discussions/707
"znrb_db81ge24jctwx8lo", # https://github.com/home-assistant/core/issues/136513
"zwjcy_gvygg3m8", # https://github.com/orgs/home-assistant/discussions/949
"zwjcy_myd45weu", # https://github.com/orgs/home-assistant/discussions/482
]
FIXTURES_DIR = pathlib.Path(__file__).parent / "fixtures"
DEVICE_MOCKS = sorted(
str(path.relative_to(FIXTURES_DIR).with_suffix(""))
for path in FIXTURES_DIR.glob("*.json")
)
class MockDeviceListener(DeviceListener):

View File

@@ -10376,12 +10376,14 @@
'state': '0.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.kattenbak_excretion_time_day-entry]
# name: test_platform_setup_and_discovery[sensor.kattenbak_excretion_duration-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -10389,7 +10391,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kattenbak_excretion_time_day',
'entity_id': 'sensor.kattenbak_excretion_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -10404,7 +10406,7 @@
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Excretion time (day)',
'original_name': 'Excretion duration',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -10414,15 +10416,16 @@
'unit_of_measurement': 's',
})
# ---
# name: test_platform_setup_and_discovery[sensor.kattenbak_excretion_time_day-state]
# name: test_platform_setup_and_discovery[sensor.kattenbak_excretion_duration-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Kattenbak Excretion time (day)',
'friendly_name': 'Kattenbak Excretion duration',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 's',
}),
'context': <ANY>,
'entity_id': 'sensor.kattenbak_excretion_time_day',
'entity_id': 'sensor.kattenbak_excretion_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -10478,6 +10481,54 @@
'state': '1.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.kattenbak_status-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.kattenbak_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Status',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cat_litter_box_status',
'unique_id': 'tuya.yohkwjjdjlzludd3psmstatus',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[sensor.kattenbak_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Kattenbak Status',
}),
'context': <ANY>,
'entity_id': 'sensor.kattenbak_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'standby',
})
# ---
# name: test_platform_setup_and_discovery[sensor.keller_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -15243,12 +15294,14 @@
'state': '3.6',
})
# ---
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_excretion_time_day-entry]
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_excretion_duration-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
@@ -15256,7 +15309,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poopy_nano_2_excretion_time_day',
'entity_id': 'sensor.poopy_nano_2_excretion_duration',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -15271,7 +15324,7 @@
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Excretion time (day)',
'original_name': 'Excretion duration',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -15281,15 +15334,16 @@
'unit_of_measurement': 's',
})
# ---
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_excretion_time_day-state]
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_excretion_duration-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Poopy Nano 2 Excretion time (day)',
'friendly_name': 'Poopy Nano 2 Excretion duration',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 's',
}),
'context': <ANY>,
'entity_id': 'sensor.poopy_nano_2_excretion_time_day',
'entity_id': 'sensor.poopy_nano_2_excretion_duration',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -15345,6 +15399,54 @@
'state': '4.0',
})
# ---
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_status-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': 'sensor',
'entity_category': None,
'entity_id': 'sensor.poopy_nano_2_status',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Status',
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'cat_litter_box_status',
'unique_id': 'tuya.nyriu7sjgj9oruzmpsmstatus',
'unit_of_measurement': None,
})
# ---
# name: test_platform_setup_and_discovery[sensor.poopy_nano_2_status-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Poopy Nano 2 Status',
}),
'context': <ANY>,
'entity_id': 'sensor.poopy_nano_2_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_platform_setup_and_discovery[sensor.production_total_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -1885,6 +1885,62 @@
'state': '0',
})
# ---
# name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.wired_client_link_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.wired_client_link_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Link speed',
'platform': 'unifi',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wired_client_link_speed',
'unique_id': 'wired_speed-00:00:00:00:00:01',
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
})
# ---
# name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.wired_client_link_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'data_rate',
'friendly_name': 'Wired client Link speed',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfDataRate.MEGABITS_PER_SECOND: 'Mbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.wired_client_link_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1000',
})
# ---
# name: test_entity_and_device_data[wlan_payload0-device_payload0-client_payload0-config_entry_options0][sensor.wired_client_rx-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -55,6 +55,7 @@ WIRED_CLIENT = {
"wired-rx_bytes-r": 1234000000,
"wired-tx_bytes-r": 5678000000,
"uptime": 1600094505,
"wired_rate_mbps": 1000,
}
WIRELESS_CLIENT = {
"is_wired": False,
@@ -542,6 +543,42 @@ async def test_bandwidth_sensors(
assert hass.states.get("sensor.wired_client_tx")
@pytest.mark.parametrize("client_payload", [[WIRED_CLIENT]])
@pytest.mark.usefixtures("config_entry_setup")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_wired_client_speed_sensor(
hass: HomeAssistant,
mock_websocket_message: WebsocketMessageMock,
client_payload: list[dict[str, Any]],
) -> None:
"""Verify that wired client speed sensor is working as expected."""
# Verify sensor is created and has correct state
assert hass.states.get("sensor.wired_client_link_speed").state == "1000"
# Verify state update
wired_client = deepcopy(client_payload[0])
wired_client["wired_rate_mbps"] = 2500
mock_websocket_message(message=MessageKey.CLIENT, data=wired_client)
await hass.async_block_till_done()
assert hass.states.get("sensor.wired_client_link_speed").state == "2500"
# Verify sensor is unavailable when client disconnects
new_time = dt_util.utcnow()
wired_client["last_seen"] = dt_util.as_timestamp(new_time)
mock_websocket_message(message=MessageKey.CLIENT, data=wired_client)
await hass.async_block_till_done()
new_time += timedelta(seconds=(DEFAULT_DETECTION_TIME + 1))
with freeze_time(new_time):
async_fire_time_changed(hass, new_time)
await hass.async_block_till_done()
assert hass.states.get("sensor.wired_client_link_speed").state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
"config_entry_options",
[{CONF_ALLOW_BANDWIDTH_SENSORS: True, CONF_ALLOW_UPTIME_SENSORS: True}],
@@ -555,9 +592,10 @@ async def test_remove_sensors(
client_payload: list[dict[str, Any]],
) -> None:
"""Verify removing of clients work as expected."""
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 6
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 7
assert hass.states.get("sensor.wired_client_rx")
assert hass.states.get("sensor.wired_client_tx")
assert hass.states.get("sensor.wired_client_link_speed")
assert hass.states.get("sensor.wired_client_uptime")
assert hass.states.get("sensor.wireless_client_rx")
assert hass.states.get("sensor.wireless_client_tx")
@@ -570,6 +608,7 @@ async def test_remove_sensors(
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3
assert hass.states.get("sensor.wired_client_rx") is None
assert hass.states.get("sensor.wired_client_tx") is None
assert hass.states.get("sensor.wired_client_link_speed") is None
assert hass.states.get("sensor.wired_client_uptime") is None
assert hass.states.get("sensor.wireless_client_rx")
assert hass.states.get("sensor.wireless_client_tx")

View File

@@ -0,0 +1 @@
"""Tests for the Victron Bluetooth Low Energy integration."""

View File

@@ -0,0 +1,75 @@
"""Test the Victron Bluetooth Low Energy config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from homeassistant.components.victron_ble.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from homeassistant.core import HomeAssistant
from .fixtures import VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.victron_ble.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_discovered_service_info() -> Generator[AsyncMock]:
"""Mock discovered service info."""
with patch(
"homeassistant.components.victron_ble.config_flow.async_discovered_service_info",
return_value=[VICTRON_VEBUS_SERVICE_INFO],
) as mock_discovered_service_info:
yield mock_discovered_service_info
@pytest.fixture
def service_info() -> BluetoothServiceInfo:
"""Return service info."""
return VICTRON_VEBUS_SERVICE_INFO
@pytest.fixture
def access_token() -> str:
"""Return access token."""
return VICTRON_VEBUS_TOKEN
@pytest.fixture
def mock_config_entry(
service_info: BluetoothServiceInfo, access_token: str
) -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: service_info.address,
CONF_ACCESS_TOKEN: access_token,
},
unique_id=service_info.address,
)
@pytest.fixture
def mock_config_entry_added_to_hass(
mock_config_entry,
hass: HomeAssistant,
service_info: BluetoothServiceInfo,
access_token: str,
) -> MockConfigEntry:
"""Mock config entry factory that added to hass."""
entry = mock_config_entry
entry.add_to_hass(hass)
return entry

View File

@@ -0,0 +1,147 @@
"""Fixtures for testing victron_ble."""
from home_assistant_bluetooth import BluetoothServiceInfo
NOT_VICTRON_SERVICE_INFO = BluetoothServiceInfo(
name="Not it",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
manufacturer_data={3234: b"\x00\x01"},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_TEST_WRONG_TOKEN = "00000000000000000000000000000000"
# battery monitor
VICTRON_BATTERY_MONITOR_SERVICE_INFO = BluetoothServiceInfo(
name="Battery Monitor",
address="01:02:03:04:05:07",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100289a302b040af925d09a4d89aa0128bdef48c6298a9")
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_BATTERY_MONITOR_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd"
VICTRON_BATTERY_MONITOR_SENSORS = {
"battery_monitor_aux_mode": "disabled",
"battery_monitor_consumed_ampere_hours": "-50.0",
"battery_monitor_current": "0.0",
"battery_monitor_remaining_minutes": "unknown",
"battery_monitor_state_of_charge": "50.0",
"battery_monitor_voltage": "12.53",
"battery_monitor_alarm": "none",
"battery_monitor_temperature": "unknown",
"battery_monitor_starter_voltage": "unknown",
"battery_monitor_midpoint_voltage": "unknown",
}
# DC/DC converter
VICTRON_DC_DC_CONVERTER_SERVICE_INFO = BluetoothServiceInfo(
name="DC/DC Converter",
address="01:02:03:04:05:08",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("1000c0a304121d64ca8d442b90bbdf6a8cba"),
},
service_data={},
service_uuids=[],
source="local",
)
# DC energy meter
VICTRON_DC_ENERGY_METER_SERVICE_INFO = BluetoothServiceInfo(
name="DC Energy Meter",
address="01:02:03:04:05:09",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100289a30d787fafde83ccec982199fd815286"),
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_DC_ENERGY_METER_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd"
VICTRON_DC_ENERGY_METER_SENSORS = {
"dc_energy_meter_meter_type": "dc_dc_charger",
"dc_energy_meter_aux_mode": "starter_voltage",
"dc_energy_meter_current": "0.0",
"dc_energy_meter_voltage": "12.52",
"dc_energy_meter_starter_voltage": "-0.01",
"dc_energy_meter_alarm": "none",
"dc_energy_meter_temperature": "unknown",
}
# Inverter
VICTRON_INVERTER_SERVICE_INFO = BluetoothServiceInfo(
name="Inverter",
address="01:02:03:04:05:10",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("1003a2a2031252dad26f0b8eb39162074d140df410"),
}, # not a valid advertisement, but model id mangled to match inverter
service_data={},
service_uuids=[],
source="local",
)
# Solar charger
VICTRON_SOLAR_CHARGER_SERVICE_INFO = BluetoothServiceInfo(
name="Solar Charger",
address="01:02:03:04:05:11",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100242a0016207adceb37b605d7e0ee21b24df5c"),
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_SOLAR_CHARGER_TOKEN = "adeccb947395801a4dd45a2eaa44bf17"
VICTRON_SOLAR_CHARGER_SENSORS = {
"solar_charger_charge_state": "absorption",
"solar_charger_battery_voltage": "13.88",
"solar_charger_battery_current": "1.4",
"solar_charger_yield_today": "30",
"solar_charger_solar_power": "19",
"solar_charger_external_device_load": "0.0",
}
# ve.bus
VICTRON_VEBUS_SERVICE_INFO = BluetoothServiceInfo(
name="Inverter Charger",
address="01:02:03:04:05:06",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100380270c1252dad26f0b8eb39162074d140df410")
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_VEBUS_TOKEN = "da3f5fa2860cb1cf86ba7a6d1d16b9dd"
VICTRON_VEBUS_SENSORS = {
"inverter_charger_device_state": "float",
"inverter_charger_battery_voltage": "14.45",
"inverter_charger_battery_current": "23.2",
"inverter_charger_ac_in_state": "AC_IN_1",
"inverter_charger_ac_in_power": "1459",
"inverter_charger_ac_out_power": "1046",
"inverter_charger_battery_temperature": "32",
"inverter_charger_state_of_charge": "unknown",
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
"""Test the Victron Bluetooth Low Energy config flow."""
from unittest.mock import AsyncMock
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from homeassistant import config_entries
from homeassistant.components.victron_ble.const import DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .fixtures import (
NOT_VICTRON_SERVICE_INFO,
VICTRON_INVERTER_SERVICE_INFO,
VICTRON_TEST_WRONG_TOKEN,
VICTRON_VEBUS_SERVICE_INFO,
VICTRON_VEBUS_TOKEN,
)
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None) -> None:
"""Mock bluetooth for all tests in this module."""
async def test_async_step_bluetooth_valid_device(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
# test valid access token
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ACCESS_TOKEN: VICTRON_VEBUS_TOKEN},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == VICTRON_VEBUS_SERVICE_INFO.name
flow_result = result.get("result")
assert flow_result is not None
assert flow_result.unique_id == VICTRON_VEBUS_SERVICE_INFO.address
assert flow_result.data == {
CONF_ACCESS_TOKEN: VICTRON_VEBUS_TOKEN,
}
assert set(flow_result.data.keys()) == {CONF_ACCESS_TOKEN}
@pytest.mark.parametrize(
("source", "service_info", "expected_reason"),
[
(
SOURCE_BLUETOOTH,
NOT_VICTRON_SERVICE_INFO,
"not_supported",
),
(
SOURCE_BLUETOOTH,
VICTRON_INVERTER_SERVICE_INFO,
"not_supported",
),
(
SOURCE_USER,
None,
"no_devices_found",
),
],
ids=["bluetooth_not_victron", "bluetooth_unsupported_device", "user_no_devices"],
)
async def test_abort_scenarios(
hass: HomeAssistant,
source: str,
service_info: BluetoothServiceInfo | None,
expected_reason: str,
) -> None:
"""Test flows that result in abort."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=service_info,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == expected_reason
async def test_async_step_user_with_devices_found(
hass: HomeAssistant, mock_discovered_service_info: AsyncMock
) -> None:
"""Test setup from service info cache with devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: VICTRON_VEBUS_SERVICE_INFO.address},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
# test invalid access token (valid already tested above)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ACCESS_TOKEN: VICTRON_TEST_WRONG_TOKEN}
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "invalid_access_token"
async def test_async_step_user_device_added_between_steps(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test abort when the device gets added via another flow between steps."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"address": VICTRON_VEBUS_SERVICE_INFO.address},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
async def test_async_step_user_with_found_devices_already_setup(
hass: HomeAssistant,
mock_config_entry_added_to_hass: MockConfigEntry,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test setup from service info cache with devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "no_devices_found"
async def test_async_step_bluetooth_devices_already_setup(
hass: HomeAssistant, mock_config_entry_added_to_hass: MockConfigEntry
) -> None:
"""Test we can't start a flow if there is already a config entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> None:
"""Test we can't start a flow for the same device twice."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_in_progress"

View File

@@ -0,0 +1,61 @@
"""Test updating sensors in the victron_ble integration."""
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .fixtures import (
VICTRON_BATTERY_MONITOR_SERVICE_INFO,
VICTRON_BATTERY_MONITOR_TOKEN,
VICTRON_DC_ENERGY_METER_SERVICE_INFO,
VICTRON_DC_ENERGY_METER_TOKEN,
VICTRON_SOLAR_CHARGER_SERVICE_INFO,
VICTRON_SOLAR_CHARGER_TOKEN,
VICTRON_VEBUS_SERVICE_INFO,
VICTRON_VEBUS_TOKEN,
)
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.parametrize(
(
"service_info",
"access_token",
),
[
(VICTRON_BATTERY_MONITOR_SERVICE_INFO, VICTRON_BATTERY_MONITOR_TOKEN),
(VICTRON_DC_ENERGY_METER_SERVICE_INFO, VICTRON_DC_ENERGY_METER_TOKEN),
(VICTRON_SOLAR_CHARGER_SERVICE_INFO, VICTRON_SOLAR_CHARGER_TOKEN),
(VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN),
],
ids=["battery_monitor", "dc_energy_meter", "solar_charger", "vebus"],
)
async def test_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry_added_to_hass: MockConfigEntry,
service_info: BluetoothServiceInfo,
access_token: str,
) -> None:
"""Test sensor entities."""
entry = mock_config_entry_added_to_hass
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Initially no entities should be created until bluetooth data is received
assert len(hass.states.async_all()) == 0
# Inject bluetooth service info to trigger entity creation
inject_bluetooth_service_info(hass, service_info)
await hass.async_block_till_done()
# Use snapshot testing to verify all entity states and registry entries
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)

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