diff --git a/CODEOWNERS b/CODEOWNERS index b868e9eee5df36..8f541414a3c3af 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/brands/victron.json b/homeassistant/brands/victron.json new file mode 100644 index 00000000000000..e8508b389aa063 --- /dev/null +++ b/homeassistant/brands/victron.json @@ -0,0 +1,5 @@ +{ + "domain": "victron", + "name": "Victron", + "integrations": ["victron_ble", "victron_remote_monitoring"] +} diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index 230d172ca91a44..3237bb37716f5c 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -6,9 +6,8 @@ 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 @@ 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"], diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e788fdf9714db2..d87dc34c27b3dc 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -7,6 +7,8 @@ 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 @@ 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 .const import ( ANALYTICS_ENDPOINT_URL, ANALYTICS_ENDPOINT_URL_DEV, + ANALYTICS_SNAPSHOT_ENDPOINT_URL, ATTR_ADDON_COUNT, ATTR_ADDONS, ATTR_ARCH, @@ -71,6 +82,7 @@ ATTR_PROTECTED, ATTR_RECORDER, ATTR_SLUG, + ATTR_SNAPSHOTS, ATTR_STATE_COUNT, ATTR_STATISTICS, ATTR_SUPERVISOR, @@ -80,8 +92,10 @@ 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 @@ def from_dict(cls, data: dict[str, Any]) -> AnalyticsData: data["onboarded"], data["preferences"], data["uuid"], + data.get("submission_identifier"), + data.get("snapshot_submission_time"), ) @@ -219,8 +240,10 @@ def __init__(self, hass: HomeAssistant) -> None: """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 @@ def preferences(self) -> dict: 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 @@ def uuid(self) -> str | None: 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 @@ async def load(self) -> None: ): 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 @@ async def save_preferences(self, preferences: dict) -> None: 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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 @@ async def send_analytics(self, _: datetime | None = None) -> None: 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 @@ def _async_should_report_integration( 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 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]: 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), } diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 6f74cc60f84cf0..44659763c176ff 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -7,6 +7,8 @@ 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_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 @@ 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, diff --git a/homeassistant/components/apcupsd/const.py b/homeassistant/components/apcupsd/const.py index 974c860afb81ab..883e0246cdf3d7 100644 --- a/homeassistant/components/apcupsd/const.py +++ b/homeassistant/components/apcupsd/const.py @@ -7,3 +7,26 @@ # 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", +} diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 3a18bea1a8a92b..7fde54194e0ea2 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -4,6 +4,8 @@ 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 @@ 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 @@ def _update_attrs(self) -> None: 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}") diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index 07ea917b54fcd5..a3070982c8068d 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -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" + } } } diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 41923612cc5eef..1f74c03a08a803 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.7.0"] + "requirements": ["aioautomower==2.7.1"] } diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 77d1bb4e709f86..1e08f36cf723e5 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -4,6 +4,7 @@ from functools import partial import logging +from typing import cast import pypck from pypck.connection import ( @@ -48,7 +49,6 @@ ) 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): diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 53eb86e01273b0..4ca5b0003d7196 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -100,8 +100,6 @@ def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: 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 @@ def temperature_unit(self) -> str: 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 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: ): 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 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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 @@ def input_received(self, input_obj: InputType) -> None: 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() diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 62a9920fb73109..d4f211ad8ef8d7 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -120,7 +120,7 @@ async def async_step_user( errors={CONF_BASE: error}, ) - data: dict = { + data: dict[str, Any] = { **user_input, CONF_DEVICES: [], CONF_ENTITIES: [], diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 951bb353bfcf6b..82fae01d9181f4 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -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 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: 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 @@ def input_received(self, input_obj: InputType) -> None: ) 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 diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index d856a5f7eaf84a..1de4eca3c64cd5 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -35,7 +35,7 @@ def __init__( 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={ diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 5a55d7c56c5111..af756ddf27bb49 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -61,7 +61,7 @@ class 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} " diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8b442d2aad3ea5..f9d5ceb9455dd3 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -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.4", "lcn-frontend==0.2.7"] } diff --git a/homeassistant/components/lcn/quality_scale.yaml b/homeassistant/components/lcn/quality_scale.yaml index 35d76a2ebdc8fb..eb1ae961aa36a4 100644 --- a/homeassistant/components/lcn/quality_scale.yaml +++ b/homeassistant/components/lcn/quality_scale.yaml @@ -74,4 +74,4 @@ rules: status: exempt comment: | Integration is not making any HTTP requests. - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index d9d76cd17920df..f6a1bf384c12ad 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -156,6 +156,8 @@ def input_received(self, input_obj: InputType) -> None: 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) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 25f56ba2dfeab6..76c800cd5ea8d3 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -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 @@ async def get_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) diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py index f241f1e4cf7534..203616d69a5f76 100644 --- a/homeassistant/components/nederlandse_spoorwegen/__init__.py +++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NSConfigEntry) -> bool: diff --git a/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py new file mode 100644 index 00000000000000..1172bc462b546d --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/binary_sensor.py @@ -0,0 +1,120 @@ +"""Support for Nederlandse Spoorwegen public transport.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +import logging + +from ns_api import Trip + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, INTEGRATION_TITLE, ROUTE_MODEL +from .coordinator import NSConfigEntry, NSDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 # since we use coordinator pattern + + +@dataclass(frozen=True, kw_only=True) +class NSBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Nederlandse Spoorwegen sensor entity.""" + + value_fn: Callable[[Trip], bool] + + +def get_delay(planned: datetime | None, actual: datetime | None) -> bool: + """Return True if delay is present, False otherwise.""" + return bool(planned and actual and planned != actual) + + +BINARY_SENSOR_DESCRIPTIONS = [ + NSBinarySensorEntityDescription( + key="is_departure_delayed", + translation_key="is_departure_delayed", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda trip: get_delay( + trip.departure_time_planned, trip.departure_time_actual + ), + entity_registry_enabled_default=False, + ), + NSBinarySensorEntityDescription( + key="is_arrival_delayed", + translation_key="is_arrival_delayed", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda trip: get_delay( + trip.arrival_time_planned, trip.arrival_time_actual + ), + entity_registry_enabled_default=False, + ), + NSBinarySensorEntityDescription( + key="is_going", + translation_key="is_going", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda trip: trip.going, + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the departure sensor from a config entry.""" + + coordinators = config_entry.runtime_data + + for subentry_id, coordinator in coordinators.items(): + async_add_entities( + ( + NSBinarySensor(coordinator, subentry_id, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ), + config_subentry_id=subentry_id, + ) + + +class NSBinarySensor(CoordinatorEntity[NSDataUpdateCoordinator], BinarySensorEntity): + """Generic NS binary sensor based on entity description.""" + + _attr_has_entity_name = True + _attr_attribution = "Data provided by NS" + entity_description: NSBinarySensorEntityDescription + + def __init__( + self, + coordinator: NSDataUpdateCoordinator, + subentry_id: str, + description: NSBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._subentry_id = subentry_id + self._attr_unique_id = f"{subentry_id}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry_id)}, + name=coordinator.name, + manufacturer=INTEGRATION_TITLE, + model=ROUTE_MODEL, + ) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not (trip := self.coordinator.data.first_trip): + return None + return self.entity_description.value_fn(trip) diff --git a/homeassistant/components/nederlandse_spoorwegen/icons.json b/homeassistant/components/nederlandse_spoorwegen/icons.json new file mode 100644 index 00000000000000..a81c3a91906d1b --- /dev/null +++ b/homeassistant/components/nederlandse_spoorwegen/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "binary_sensor": { + "is_arrival_delayed": { + "default": "mdi:bell-alert-outline" + }, + "is_departure_delayed": { + "default": "mdi:bell-alert-outline" + }, + "is_going": { + "default": "mdi:bell-cancel-outline" + } + } + } +} diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py index baa954cdbdac9e..9441f219b1235b 100644 --- a/homeassistant/components/nederlandse_spoorwegen/sensor.py +++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py @@ -155,7 +155,7 @@ async def async_setup_entry( class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity): - """Implementation of a NS Departure Sensor.""" + """Implementation of a NS Departure Sensor (legacy).""" _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_attribution = "Data provided by NS" @@ -202,6 +202,8 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if not first_trip: return None + status = first_trip.status + return { "going": first_trip.going, "departure_time_planned": _get_time_str(first_trip.departure_time_planned), @@ -221,7 +223,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: "arrival_platform_planned": first_trip.arrival_platform_planned, "arrival_platform_actual": first_trip.arrival_platform_actual, "next": _get_time_str(_get_departure_time(next_trip)), - "status": first_trip.status.lower() if first_trip.status else None, + "status": status.lower() if status else None, "transfers": first_trip.nr_transfers, "route": _get_route(first_trip), "remarks": None, diff --git a/homeassistant/components/nederlandse_spoorwegen/strings.json b/homeassistant/components/nederlandse_spoorwegen/strings.json index ac2334158f6539..bb07fc61a47947 100644 --- a/homeassistant/components/nederlandse_spoorwegen/strings.json +++ b/homeassistant/components/nederlandse_spoorwegen/strings.json @@ -64,6 +64,19 @@ } } }, + "entity": { + "binary_sensor": { + "is_arrival_delayed": { + "name": "Arrival delayed" + }, + "is_departure_delayed": { + "name": "Departure delayed" + }, + "is_going": { + "name": "Going" + } + } + }, "issues": { "deprecated_yaml_import_issue_cannot_connect": { "description": "Configuring Nederlandse Spoorwegen using YAML sensor platform is deprecated.\n\nWhile importing your configuration, Home Assistant could not connect to the NS API. Please check your internet connection and the status of the NS API, then restart Home Assistant to try again, or remove the existing YAML configuration and set the integration up via the UI.", diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py index c304bfdf72d443..6ed86c183ef9b7 100644 --- a/homeassistant/components/ohme/__init__.py +++ b/homeassistant/components/ohme/__init__.py @@ -11,7 +11,6 @@ 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), ) diff --git a/homeassistant/components/ohme/coordinator.py b/homeassistant/components/ohme/coordinator.py index d9e009ed1f10d9..71ac7e1794f3b8 100644 --- a/homeassistant/components/ohme/coordinator.py +++ b/homeassistant/components/ohme/coordinator.py @@ -10,7 +10,7 @@ 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 @@ async def _internal_update_data(self) -> None: 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.""" diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 14612fff6eb899..e3677b26215214 100644 --- a/homeassistant/components/ohme/manifest.json +++ b/homeassistant/components/ohme/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["ohme==1.5.2"] + "requirements": ["ohme==1.6.0"] } diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py index 7047e33749f182..930639660504a2 100644 --- a/homeassistant/components/ohme/sensor.py +++ b/homeassistant/components/ohme/sensor.py @@ -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 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription): ), ] -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) ) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 10655b69535174..a24e133bd47cc7 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1919,8 +1919,23 @@ def __init__( super().__init__(coordinator, key, attribute, description, entry) self.restored_data: SensorExtraStoredData | None = None - if hasattr(self, "_attr_name"): - delattr(self, "_attr_name") + if coordinator.device.initialized: + if hasattr(self, "_attr_name"): + delattr(self, "_attr_name") + + translation_placeholders, translation_key = ( + get_entity_translation_attributes( + get_rpc_channel_name(coordinator.device, key), + description.translation_key, + description.device_class, + self._default_to_device_class_name(), + ) + ) + + if translation_placeholders: + self._attr_translation_placeholders = translation_placeholders + if translation_key: + self._attr_translation_key = translation_key async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py new file mode 100644 index 00000000000000..20c524d1f9c5a2 --- /dev/null +++ b/homeassistant/components/victron_ble/__init__.py @@ -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 diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py new file mode 100644 index 00000000000000..eaf0bbab225e33 --- /dev/null +++ b/homeassistant/components/victron_ble/config_flow.py @@ -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)} + ), + ) diff --git a/homeassistant/components/victron_ble/const.py b/homeassistant/components/victron_ble/const.py new file mode 100644 index 00000000000000..8ea195eb17a1ea --- /dev/null +++ b/homeassistant/components/victron_ble/const.py @@ -0,0 +1,4 @@ +"""Constants for the Victron Bluetooth Low Energy integration.""" + +DOMAIN = "victron_ble" +VICTRON_IDENTIFIER = 0x02E1 diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json new file mode 100644 index 00000000000000..968fd27dec0ff1 --- /dev/null +++ b/homeassistant/components/victron_ble/manifest.json @@ -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"] +} diff --git a/homeassistant/components/victron_ble/quality_scale.yaml b/homeassistant/components/victron_ble/quality_scale.yaml new file mode 100644 index 00000000000000..5eedb4ea163a43 --- /dev/null +++ b/homeassistant/components/victron_ble/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py new file mode 100644 index 00000000000000..244f8150d771df --- /dev/null +++ b/homeassistant/components/victron_ble/sensor.py @@ -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) diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json new file mode 100644 index 00000000000000..25967f4f8e4841 --- /dev/null +++ b/homeassistant/components/victron_ble/strings.json @@ -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" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index dbc6b13cbab1b2..9541e82aecd5d7 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -849,6 +849,14 @@ "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", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fa45a9defb1532..0d697fd37636e4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -723,6 +723,7 @@ "version", "vesync", "vicare", + "victron_ble", "victron_remote_monitoring", "vilfo", "vizio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e79a50ba69f5a2..3256a7019eb525 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/requirements_all.txt b/requirements_all.txt index d504da37fa5510..54d5e573f6476b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 @@ -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 @@ -2269,7 +2269,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.9.3 +pypck==0.9.4 # homeassistant.components.pglab pypglab==0.0.5 @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 11569c517c5476..2b83e891742022 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 @@ -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 @@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.9.3 +pypck==0.9.4 # homeassistant.components.pglab pypglab==0.0.5 @@ -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 diff --git a/tests/common.py b/tests/common.py index 419ba0ad4666ad..2ee2709cde1951 100644 --- a/tests/common.py +++ b/tests/common.py @@ -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, ) diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ec35cc56d51a07..633ce412c4774b 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -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.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.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 tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform +from homeassistant.util import dt as dt_util + +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 async_modify_analytics( }, }, } + + +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 diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index 66000fc5936cc6..3addc8ad9f9160 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -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"}) diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index 4e9626bec6b5a7..73b349650b80b5 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -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': , + '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': , + 'translation_key': 'apc_deprecated', + 'translation_placeholders': dict({ + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'model', + 'device_id': '', + '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': , + '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': , + 'translation_key': 'date_deprecated', + 'translation_placeholders': dict({ + 'device_id': '', + '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': , + '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': , + 'translation_key': 'date_deprecated', + 'translation_placeholders': dict({ + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'hw_version', + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'model', + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'serial_number', + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'name', + 'device_id': '', + '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': , + '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': , + 'translation_key': 'available_via_device_info', + 'translation_placeholders': dict({ + 'available_via_device_attr': 'sw_version', + 'device_id': '', + '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({ diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index 9dadffe6fb3169..2842bdf1258d52 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -6,7 +6,8 @@ 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 @@ 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"] = "" + 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 diff --git a/tests/components/nederlandse_spoorwegen/snapshots/test_binary_sensor.ambr b/tests/components/nederlandse_spoorwegen/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..e4b867960e291b --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/snapshots/test_binary_sensor.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.to_home_arrival_delayed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_home_arrival_delayed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arrival delayed', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_arrival_delayed', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-is_arrival_delayed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_home_arrival_delayed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To home Arrival delayed', + }), + 'context': , + 'entity_id': 'binary_sensor.to_home_arrival_delayed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.to_home_departure_delayed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_home_departure_delayed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Departure delayed', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_departure_delayed', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-is_departure_delayed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_home_departure_delayed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To home Departure delayed', + }), + 'context': , + 'entity_id': 'binary_sensor.to_home_departure_delayed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.to_home_going-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_home_going', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Going', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_going', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY9-is_going', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_home_going-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To home Going', + }), + 'context': , + 'entity_id': 'binary_sensor.to_home_going', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_arrival_delayed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_work_arrival_delayed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Arrival delayed', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_arrival_delayed', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY8-is_arrival_delayed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_arrival_delayed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To work Arrival delayed', + }), + 'context': , + 'entity_id': 'binary_sensor.to_work_arrival_delayed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_departure_delayed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_work_departure_delayed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Departure delayed', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_departure_delayed', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY8-is_departure_delayed', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_departure_delayed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To work Departure delayed', + }), + 'context': , + 'entity_id': 'binary_sensor.to_work_departure_delayed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_going-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.to_work_going', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Going', + 'platform': 'nederlandse_spoorwegen', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'is_going', + 'unique_id': '01K721DZPMEN39R5DK0ATBMSY8-is_going', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.to_work_going-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by NS', + 'friendly_name': 'To work Going', + }), + 'context': , + 'entity_id': 'binary_sensor.to_work_going', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nederlandse_spoorwegen/test_binary_sensor.py b/tests/components/nederlandse_spoorwegen/test_binary_sensor.py new file mode 100644 index 00000000000000..f1e969e694065e --- /dev/null +++ b/tests/components/nederlandse_spoorwegen/test_binary_sensor.py @@ -0,0 +1,75 @@ +"""Test the Nederlandse Spoorwegen binary sensor.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from requests.exceptions import ConnectionError as RequestsConnectionError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def mock_binary_sensor_platform() -> Generator: + """Override PLATFORMS for NS integration.""" + with patch( + "homeassistant.components.nederlandse_spoorwegen.PLATFORMS", + [Platform.BINARY_SENSOR], + ) as mock_platform: + yield mock_platform + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor initialization.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.freeze_time("2025-09-15 14:30:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_upcoming_trips( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor initialization.""" + mock_nsapi.get_trips.return_value = [] + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("binary_sensor.to_work_departure_delayed").state + == STATE_UNKNOWN + ) + + +async def test_sensor_with_api_connection_error( + hass: HomeAssistant, + mock_nsapi: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test sensor behavior when API connection fails.""" + # Make API calls fail from the start + mock_nsapi.get_trips.side_effect = RequestsConnectionError("Connection failed") + + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Sensors should not be created at all if initial API call fails + sensor_states = hass.states.async_all("binary_sensor") + assert len(sensor_states) == 0 diff --git a/tests/components/nederlandse_spoorwegen/test_sensor.py b/tests/components/nederlandse_spoorwegen/test_sensor.py index 96110cbe0fbcc9..53322626013a37 100644 --- a/tests/components/nederlandse_spoorwegen/test_sensor.py +++ b/tests/components/nederlandse_spoorwegen/test_sensor.py @@ -1,6 +1,7 @@ """Test the Nederlandse Spoorwegen sensor.""" -from unittest.mock import AsyncMock +from collections.abc import Generator +from unittest.mock import AsyncMock, patch import pytest from requests.exceptions import ConnectionError as RequestsConnectionError @@ -18,7 +19,7 @@ ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigSubentryDataWithId -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.entity_registry as er import homeassistant.helpers.issue_registry as ir @@ -30,6 +31,16 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +def mock_sensor_platform() -> Generator: + """Override PLATFORMS for NS integration.""" + with patch( + "homeassistant.components.nederlandse_spoorwegen.PLATFORMS", + [Platform.SENSOR], + ) as mock_platform: + yield mock_platform + + async def test_config_import( hass: HomeAssistant, mock_nsapi, diff --git a/tests/components/ohme/conftest.py b/tests/components/ohme/conftest.py index e8a7d27b2c37a7..2a830a5bfc8a34 100644 --- a/tests/components/ohme/conftest.py +++ b/tests/components/ohme/conftest.py @@ -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) diff --git a/tests/components/ohme/snapshots/test_sensor.ambr b/tests/components/ohme/snapshots/test_sensor.ambr index c22d43a451b1ad..f074aa8e6bdf33 100644 --- a/tests/components/ohme/snapshots/test_sensor.ambr +++ b/tests/components/ohme/snapshots/test_sensor.ambr @@ -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': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - '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': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 2, - }), - }), - 'original_device_class': , - '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': , - }) -# --- -# 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': , - }), - 'context': , - 'entity_id': 'sensor.ohme_home_pro_ct_current', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- # name: test_sensors[sensor.ohme_home_pro_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ohme/snapshots/test_services.ambr b/tests/components/ohme/snapshots/test_services.ambr index 91917ed69257d3..c7c0deaefdbf8e 100644 --- a/tests/components/ohme/snapshots/test_services.ambr +++ b/tests/components/ohme/snapshots/test_services.ambr @@ -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', }), ]), diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 1dde4817d4b031..8034c71aad215e 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -580,31 +580,55 @@ async def test_rpc_polling_sensor( async def test_rpc_sleeping_sensor( hass: HomeAssistant, mock_rpc_device: Mock, - device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC online sleeping sensor.""" entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" monkeypatch.setattr(mock_rpc_device, "connected", False) monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) - entry = await init_integration(hass, 2, sleep_period=1000) + await init_integration(hass, 2, sleep_period=1000) # Sensor should be created when device is online assert hass.states.get(entity_id) is None - register_entity( - hass, - SENSOR_DOMAIN, - "test_name_temperature", - "temperature:0-temperature_0", - entry, + # Make device online + mock_rpc_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity_id)) + assert state.state == "22.9" + + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "temperature:0", "tC", 23.4) + mock_rpc_device.mock_update() + + assert (state := hass.states.get(entity_id)) + assert state.state == "23.4" + + +async def test_rpc_sleeping_sensor_with_channel_name( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test RPC online sleeping sensor with channel name.""" + name = "test channel name" + entity_id = f"{SENSOR_DOMAIN}.test_name_test_channel_name_temperature" + monkeypatch.setitem( + mock_rpc_device.config, "temperature:0", {"id": 0, "name": name} ) + monkeypatch.setattr(mock_rpc_device, "connected", False) + monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 1000) + await init_integration(hass, 2, sleep_period=1000) + + # Sensor should be created when device is online + assert hass.states.get(entity_id) is None # Make device online mock_rpc_device.mock_online() await hass.async_block_till_done(wait_background_tasks=True) assert (state := hass.states.get(entity_id)) + assert state.attributes["friendly_name"] == f"Test name {name} temperature" assert state.state == "22.9" mutate_rpc_device_status(monkeypatch, mock_rpc_device, "temperature:0", "tC", 23.4) diff --git a/tests/components/victron_ble/__init__.py b/tests/components/victron_ble/__init__.py new file mode 100644 index 00000000000000..90b3e67966b819 --- /dev/null +++ b/tests/components/victron_ble/__init__.py @@ -0,0 +1 @@ +"""Tests for the Victron Bluetooth Low Energy integration.""" diff --git a/tests/components/victron_ble/conftest.py b/tests/components/victron_ble/conftest.py new file mode 100644 index 00000000000000..443c7111068900 --- /dev/null +++ b/tests/components/victron_ble/conftest.py @@ -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 diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py new file mode 100644 index 00000000000000..6b8bbf904d543f --- /dev/null +++ b/tests/components/victron_ble/fixtures.py @@ -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", +} diff --git a/tests/components/victron_ble/snapshots/test_sensor.ambr b/tests/components/victron_ble/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..cf71d0e5ef7553 --- /dev/null +++ b/tests/components/victron_ble/snapshots/test_sensor.ambr @@ -0,0 +1,1891 @@ +# serializer version: 1 +# name: test_sensors[battery_monitor][sensor.battery_monitor_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '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', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '01:02:03:04:05:07-alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Battery Monitor Alarm', + 'options': list([ + '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', + ]), + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Battery Monitor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_consumed_ampere_hours-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_consumed_ampere_hours', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Consumed ampere hours', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:07-consumed_ampere_hours', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_consumed_ampere_hours-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Battery Monitor Consumed ampere hours', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_consumed_ampere_hours', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-50.0', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Battery Monitor Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_midpoint_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_midpoint_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Midpoint voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:07-midpoint_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_midpoint_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Battery Monitor Midpoint voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_midpoint_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_remaining_minutes-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_remaining_minutes', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining minutes', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:07-remaining_minutes', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_remaining_minutes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Battery Monitor Remaining minutes', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_remaining_minutes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Battery Monitor Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Battery Monitor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Battery Monitor Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.53', + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_monitor_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:07-starter_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_monitor][sensor.battery_monitor_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Battery Monitor Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_monitor_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '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', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm', + 'unique_id': '01:02:03:04:05:09-alarm', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'DC Energy Meter Alarm', + 'options': list([ + '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', + ]), + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:09-current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'DC Energy Meter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_meter_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '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', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_meter_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter type', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'meter_type', + 'unique_id': '01:02:03:04:05:09-meter_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_meter_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'DC Energy Meter Meter type', + 'options': list([ + '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', + ]), + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_meter_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'dc_dc_charger', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:09-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'DC Energy Meter Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:09-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'DC Energy Meter Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:09-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'DC Energy Meter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.52', + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_voltage_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.dc_energy_meter_voltage_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:09-starter_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[dc_energy_meter][sensor.dc_energy_meter_voltage_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'DC Energy Meter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.dc_energy_meter_voltage_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-0.01', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_battery_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_battery_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery current', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:11-battery_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_battery_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Solar Charger Battery current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solar_charger_battery_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.4', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:11-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Solar Charger Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solar_charger_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '13.88', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_external_device_load-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_external_device_load', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'External device load', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:11-external_device_load', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_external_device_load-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Solar Charger External device load', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solar_charger_external_device_load', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:11-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Solar Charger Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.solar_charger_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_solar_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_solar_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar power', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:11-solar_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_solar_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Solar Charger Solar power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solar_charger_solar_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19', + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_yield_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.solar_charger_yield_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Yield today', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:11-yield_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[solar_charger][sensor.solar_charger_yield_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Solar Charger Yield today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.solar_charger_yield_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_in_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_ac_in_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC-in power', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:06-ac_in_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_in_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter Charger AC-in power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_ac_in_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1459', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_in_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'ac_in_1', + 'ac_in_2', + 'not_connected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_ac_in_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC-in state', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_in_state', + 'unique_id': '01:02:03:04:05:06-ac_in_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_in_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Inverter Charger AC-in state', + 'options': list([ + 'ac_in_1', + 'ac_in_2', + 'not_connected', + ]), + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_ac_in_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'ac_in_1', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_out_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_ac_out_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC-out power', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:06-ac_out_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_ac_out_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter Charger AC-out power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_ac_out_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1046', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:06-state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Inverter Charger Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_battery_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery current', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:06-battery_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter Charger Battery current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_battery_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.2', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:06-battery_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter Charger Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_battery_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '01:02:03:04:05:06-battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter Charger Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14.45', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_device_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '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', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_device_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device state', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'device_state', + 'unique_id': '01:02:03:04:05:06-device_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_device_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Inverter Charger Device state', + 'options': list([ + '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', + ]), + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_device_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'float', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_charger_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'victron_ble', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01:02:03:04:05:06-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensors[vebus][sensor.inverter_charger_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Inverter Charger Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.inverter_charger_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- diff --git a/tests/components/victron_ble/test_config_flow.py b/tests/components/victron_ble/test_config_flow.py new file mode 100644 index 00000000000000..026c91b539f664 --- /dev/null +++ b/tests/components/victron_ble/test_config_flow.py @@ -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" diff --git a/tests/components/victron_ble/test_sensor.py b/tests/components/victron_ble/test_sensor.py new file mode 100644 index 00000000000000..ebbf5657d25d51 --- /dev/null +++ b/tests/components/victron_ble/test_sensor.py @@ -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) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 52cecd8420f194..4f000ad73981bb 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1029,7 +1029,9 @@ def load_yaml(fname, secrets=None): ): descriptions = await service.async_get_all_descriptions(hass) - mock_load_yaml.assert_called_once_with("services.yaml", None) + mock_load_yaml.assert_called_once_with( + "homeassistant/components/test_domain/services.yaml", None + ) assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), @@ -1117,7 +1119,9 @@ def load_yaml(fname, secrets=None): ): descriptions = await service.async_get_all_descriptions(hass) - mock_load_yaml.assert_called_once_with("services.yaml", None) + mock_load_yaml.assert_called_once_with( + "homeassistant/components/test_domain/services.yaml", None + ) assert proxy_load_services_files.mock_calls[0][1][0] == unordered( [ await async_get_integration(hass, domain), diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index e220b1f4574317..3f1f481928a127 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -84,7 +84,10 @@ async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None If this test fails, the evict_faked_translations may need to be updated. """ integration = mock_integration(hass, MockModule("test"), built_in=True) - assert integration.file_path == pathlib.Path("") + assert integration.file_path == pathlib.Path("homeassistant/components/test") + + integration = mock_integration(hass, MockModule("test"), built_in=False) + assert integration.file_path == pathlib.Path("custom_components/test") async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None: