Compare commits

...

10 Commits

Author SHA1 Message Date
Erik Montnemery
f0981c00e5 Revert "Return target in trigger description command (#156766)"
This reverts commit 343ea1b82d.
2025-11-19 07:58:17 +01:00
Andre Lengwenus
82d3190016 Bump pypck to 0.9.5 (#156847) 2025-11-19 06:52:29 +01:00
omrishiv
d8cbcc1977 Bump pylutron-caseta to 0.26.0 (#156825)
Signed-off-by: omrishiv <327609+omrishiv@users.noreply.github.com>
2025-11-18 23:01:34 +01:00
Raj Laud
4b69543515 Add support for Victron bluetooth low energy devices (#148043)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 21:12:48 +01:00
Thomas55555
97ef4a35b9 Bump aioautomower to 2.7.1 (#156826) 2025-11-18 20:32:47 +01:00
Dan Raper
f782c78650 Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-18 19:52:17 +01:00
Abílio Costa
139ed34c74 Properly mock integrations' file_path (#156813) 2025-11-18 18:42:35 +01:00
Andre Lengwenus
7f14d013ac Strict typing for lcn integration (#156800) 2025-11-18 18:26:24 +01:00
Artur Pragacz
963e27dda4 Send snapshot analytics for device database in dev (#155717) 2025-11-18 16:15:27 +00:00
Yuxin Wang
b8e3d57fea Deprecate useless sensors in APCUPSD integration (#151525) 2025-11-18 17:09:38 +01:00
54 changed files with 4545 additions and 244 deletions

2
CODEOWNERS generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -805,8 +805,6 @@ async def async_get_all_descriptions(
continue
description = {"fields": yaml_description.get("fields", {})}
if (target := yaml_description.get("target")) is not None:
description["target"] = target
new_descriptions_cache[missing_trigger] = description

11
requirements_all.txt generated
View File

@@ -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
@@ -2150,7 +2150,7 @@ pylitejet==0.6.3
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.25.0
pylutron-caseta==0.26.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -2269,7 +2269,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.5
# homeassistant.components.pglab
pypglab==0.0.5
@@ -3088,6 +3088,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

@@ -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
@@ -1794,7 +1794,7 @@ pylitejet==0.6.3
pylitterbot==2025.0.0
# homeassistant.components.lutron_caseta
pylutron-caseta==0.25.0
pylutron-caseta==0.26.0
# homeassistant.components.lutron
pylutron==0.2.18
@@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20
pypaperless==4.1.1
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.5
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2555,6 +2555,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1029,7 +1029,9 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> 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 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> 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),

View File

@@ -647,9 +647,14 @@ async def test_async_get_all_descriptions(
"""Test async_get_all_descriptions."""
tag_trigger_descriptions = """
_:
target:
fields:
entity:
domain: alarm_control_panel
selector:
entity:
filter:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
"""
assert await async_setup_component(hass, DOMAIN_SUN, {})
@@ -739,14 +744,22 @@ async def test_async_get_all_descriptions(
}
},
"tag": {
"target": {
"entity": [
{
"domain": ["alarm_control_panel"],
}
],
},
"fields": {},
"fields": {
"entity": {
"selector": {
"entity": {
"filter": [
{
"domain": ["alarm_control_panel"],
"supported_features": [1],
}
],
"multiple": False,
"reorder": False,
},
},
},
}
},
}
@@ -878,5 +891,6 @@ async def test_subscribe_triggers(
trigger.async_subscribe_platform_events(hass, good_subscriber)
assert await async_setup_component(hass, "sun", {})
assert trigger_events == [{"sun"}]
assert "Error while notifying trigger platform listener" in caplog.text

View File

@@ -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: