This commit is contained in:
Home Assistant Version Control
2026-08-07 16:23:20 +00:00
parent 305e1b2545
commit baabdf0c0e
58 changed files with 678 additions and 109 deletions
File diff suppressed because one or more lines are too long
+9 -9
View File
@@ -212,7 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
global_config = get_global_configuration(hass, config)
discovery_manager = await create_discovery_manager_instance(hass, config, global_config)
discovery_manager = create_discovery_manager_instance(hass, config, global_config)
hass.data[DOMAIN] = {
DATA_DISCOVERY_MANAGER: discovery_manager,
DOMAIN_CONFIG: global_config,
@@ -225,6 +225,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DATA_ANALYTICS: {},
}
await discovery_manager.setup()
register_services(hass)
await async_load_platform(hass, Platform.SELECT, DOMAIN, {}, config)
@@ -235,8 +237,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
try:
await repair_none_config_entries_issue(hass)
except Exception as e: # pragma: no cover
_LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover
except Exception: # pragma: no cover
_LOGGER.exception("problem while cleaning up None entities") # pragma: no cover
await init_analytics(hass)
@@ -272,7 +274,7 @@ async def init_analytics(hass: HomeAssistant) -> None:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
async def create_discovery_manager_instance(
def create_discovery_manager_instance(
hass: HomeAssistant,
ha_config: ConfigType,
global_powercalc_config: ConfigType,
@@ -284,15 +286,13 @@ async def create_discovery_manager_instance(
exclude_self_usage = discovery_config.get(CONF_EXCLUDE_SELF_USAGE, False)
enable_autodiscovery = discovery_config.get(CONF_ENABLED, True)
manager = DiscoveryManager(
return DiscoveryManager(
hass,
ha_config,
exclude_device_types=exclude_device_types,
exclude_self_usage_profiles=exclude_self_usage,
enabled=enable_autodiscovery,
)
await manager.setup()
return manager
def register_services(hass: HomeAssistant) -> None:
@@ -579,8 +579,8 @@ async def repair_none_config_entries_issue(hass: HomeAssistant) -> None:
object.__setattr__(entry, "unique_id", unique_id)
hass.config_entries._entries._index_entry(entry) # noqa: SLF001
await hass.config_entries.async_remove(entry.entry_id)
except Exception as e: # pragma: no cover
_LOGGER.error("problem while cleaning up None entities", exc_info=e) # pragma: no cover
except Exception: # pragma: no cover
_LOGGER.exception("problem while cleaning up None entities") # pragma: no cover
def _notify_message(
+30 -7
View File
@@ -38,6 +38,7 @@ from .const import (
CONF_POWER,
CONF_SENSOR_TYPE,
CONF_STANDBY_POWER,
DISCOVERY_INTEGRATION_NAME,
DISCOVERY_POWER_PROFILES,
DISCOVERY_SOURCE_ENTITY,
DOMAIN,
@@ -400,6 +401,7 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
self.source_entity_id = self.source_entity.entity_id
self.name = self.source_entity.name
integration_name = discovery_info.pop(DISCOVERY_INTEGRATION_NAME, None)
power_profiles: list[PowerProfile] = []
if DISCOVERY_POWER_PROFILES in discovery_info:
@@ -410,7 +412,7 @@ class PowercalcConfigFlow(PowercalcCommonFlow, ConfigFlow, domain=DOMAIN):
self.sensor_config = discovery_info.copy()
self.context["title_placeholders"] = {
"name": self.name or "",
"name": f"{self.name} - {integration_name}" if integration_name else self.name or "",
"manufacturer": str(self.sensor_config.get(CONF_MANUFACTURER)),
"model": str(self.sensor_config.get(CONF_MODEL)),
}
@@ -549,12 +551,7 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
menu = [Step.BASIC_OPTIONS]
if self.selected_sensor_type == SensorType.VIRTUAL_POWER:
if self.strategy and self.should_add_strategy_option_to_menu():
strategy_step = STRATEGY_STEP_MAPPING[self.strategy]
menu.append(strategy_step)
if self.selected_profile:
menu.append(Step.LIBRARY_OPTIONS)
menu.append(Step.ADVANCED_OPTIONS)
menu.extend(self.build_virtual_power_menu())
if self.selected_sensor_type == SensorType.DAILY_ENERGY:
menu.append(Step.DAILY_ENERGY)
if self.selected_sensor_type == SensorType.REAL_POWER:
@@ -570,6 +567,32 @@ class PowercalcOptionsFlow(PowercalcCommonFlow, OptionsFlow):
return menu
def build_virtual_power_menu(self) -> list[Step]:
"""Build the options menu entries specific to virtual power sensors."""
menu: list[Step] = []
if self.strategy and self.should_add_strategy_option_to_menu():
menu.append(STRATEGY_STEP_MAPPING[self.strategy])
if self.selected_profile:
menu.append(Step.LIBRARY_OPTIONS)
if self.should_add_select_device_to_menu():
menu.append(Step.SELECT_DEVICE)
menu.append(Step.ADVANCED_OPTIONS)
return menu
def should_add_select_device_to_menu(self) -> bool:
"""Check whether the device selection should be added to the menu."""
if not self.selected_profile or self.selected_profile.discovery_by not in [
DiscoveryBy.DEVICE,
DiscoveryBy.CONFIG_ENTRY,
]:
return False
if self.selected_profile.discovery_by == DiscoveryBy.CONFIG_ENTRY:
# Only devices of the source config entry are selectable, so there must be something to choose from.
return len(self.flow_handlers[FlowType.LIBRARY].get_selectable_devices()) > 1
return True
def should_add_strategy_option_to_menu(self) -> bool:
"""Check whether the strategy option should be added to the menu."""
if not self.strategy or self.strategy not in STRATEGY_STEP_MAPPING:
+2
View File
@@ -240,7 +240,9 @@ DEFAULT_UTILITY_METER_TYPES = [DAILY, WEEKLY, MONTHLY]
DISCOVERY_SOURCE_ENTITY = "source_entity"
DISCOVERY_POWER_PROFILES = "power_profiles"
DISCOVERY_INTEGRATION_NAME = "integration_name"
DISCOVERY_TYPE = "discovery_type"
LIBRARY_DISCOVERY_IGNORED_DOMAINS = "discovery_ignored_domains"
LIBRARY_URL = "https://library.powercalc.nl"
API_URL = "https://api.powercalc.nl"
+61 -1
View File
@@ -31,6 +31,54 @@ def is_composite_device_id(hass: HomeAssistant, device_id: str) -> bool:
return bool(is_composite(device_id))
def get_related_device_ids(hass: HomeAssistant, device_id: str) -> set[str]:
"""
Return the IDs of all devices representing the same physical device, including `device_id` itself.
Devices are related when they were split off from the same legacy composite device, or when they
share any identifier or connection. Both happen when a single physical device is provided by more
than one config entry.
`device_id` may be the ID of a registered device, or of a legacy composite device.
"""
device_reg = device_registry.async_get(hass)
related = {device_id}
related.update(device.id for device in _get_composite_split_devices(device_reg, device_id))
device = device_reg.async_get(device_id)
if device is None:
return related
sibling_devices = _get_composite_split_devices(device_reg, getattr(device, "composite_device_id", None))
related.update(sibling_device.id for sibling_device in sibling_devices)
# Only available in HA >=2026.8. On older versions devices sharing an identifier or connection
# were merged into a single device, so there is nothing to relate.
get_devices = getattr(device_reg, "async_get_devices", None)
if callable(get_devices):
related.update(
linked_device.id
for linked_device in get_devices(identifiers=device.identifiers, connections=device.connections)
)
return related
def _get_composite_split_devices(
device_reg: device_registry.DeviceRegistry,
composite_device_id: str | None,
) -> list[DeviceEntry]:
"""
Return the devices a legacy composite device was split into.
Returns an empty list when the ID does not identify a composite device, or when running on
HA <2026.8, which does not split composite devices at all.
"""
if not composite_device_id:
return []
get_split_devices = getattr(device_reg, "async_get_devices_for_composite_device_id", None)
if not callable(get_split_devices):
return [] # pragma: no cover
return list(get_split_devices(composite_device_id))
def get_config_entry_ids(device: DeviceEntry) -> set[str]:
"""
Return the config entry IDs a device belongs to.
@@ -39,7 +87,7 @@ def get_config_entry_ids(device: DeviceEntry) -> set[str]:
"""
if _HAS_SINGLE_CONFIG_ENTRY:
return {device.config_entry_id}
return set(getattr(device, "config_entries", set()))
return set(getattr(device, "config_entries", set())) # pragma: no cover
def get_first_device_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> DeviceEntry | None:
@@ -56,6 +104,18 @@ def get_devices_for_config_entry(hass: HomeAssistant, config_entry_id: str) -> l
]
def get_related_devices(hass: HomeAssistant, device_id: str) -> list[DeviceEntry]:
"""Return all non-composite devices belonging to the same config entry as the given device."""
device = device_registry.async_get(hass).async_get(device_id)
if device is None:
return []
devices: dict[str, DeviceEntry] = {}
for config_entry_id in get_config_entry_ids(device):
devices.update({related.id: related for related in get_devices_for_config_entry(hass, config_entry_id)})
return list(devices.values())
def attach_configured_device_entry(
hass: HomeAssistant,
sensor_config: ConfigType,
+2 -2
View File
@@ -49,6 +49,6 @@ async def get_yaml_configuration(hass: HomeAssistant) -> ConfigType:
try:
yaml_config = await async_integration_yaml_config(hass, DOMAIN)
return yaml_config.get(DOMAIN, {}) # type: ignore
except Exception as err: # noqa: BLE001 # pragma: nocover
_LOGGER.error("Could not retrieve YAML config: %s", err)
except Exception: # pragma: nocover
_LOGGER.exception("Could not retrieve YAML config")
return {}
+97 -22
View File
@@ -1,4 +1,4 @@
from collections.abc import Awaitable, Callable
from collections.abc import Callable
from datetime import datetime, timedelta
from enum import StrEnum
import logging
@@ -8,14 +8,15 @@ from typing import Any, TypeVar
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER, ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.const import CONF_DEVICE, CONF_ENTITY_ID, CONF_PLATFORM, CONF_UNIQUE_ID
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.helpers import discovery_flow
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.entity import EntityCategory
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound, async_get_integration
from .common import SourceEntity, create_source_entity
from .const import (
@@ -24,6 +25,7 @@ from .const import (
CONF_MODEL,
CONF_SENSORS,
DATA_DISCOVERY_MANAGER,
DISCOVERY_INTEGRATION_NAME,
DISCOVERY_POWER_PROFILES,
DISCOVERY_SOURCE_ENTITY,
DOMAIN,
@@ -31,7 +33,12 @@ from .const import (
MANUFACTURER_WLED,
CalculationStrategy,
)
from .device_binding import get_config_entry_ids, get_first_device_for_config_entry, is_composite_device_id
from .device_binding import (
get_config_entry_ids,
get_first_device_for_config_entry,
get_related_device_ids,
is_composite_device_id,
)
from .group_include.filter import (
CategoryFilter,
CompositeFilter,
@@ -180,6 +187,8 @@ class DiscoveryManager:
continue # pragma: no cover
self.initialized_flows.add(entry.unique_id)
self._initialize_configured_device(entry)
entity_id = entry.data.get(CONF_ENTITY_ID)
if not entity_id or entity_id == DUMMY_ENTITY_ID:
continue
@@ -189,6 +198,23 @@ class DiscoveryManager:
self.initialized_flows.add(f"pc_{entity.device_entry.id}")
self.initialized_flows.add(entity_id)
def _initialize_configured_device(self, entry: ConfigEntry) -> None:
"""Mark the device a config entry was setup for as already setup.
A single physical device can be represented by several device registry entries. HA >=2026.8
splits devices belonging to multiple config entries into one device per entry, so the entry
may hold the composite device ID, which no longer resolves to a registered device, or one of
the split devices after the user resolved the composite device repair. Devices can also be
registered by several integrations, in which case they share identifiers or connections.
All of them are the device the user already configured, so none should be discovered again.
"""
device_id = entry.data.get(CONF_DEVICE)
if not device_id:
return
for related_device_id in get_related_device_ids(self.hass, str(device_id)):
self.initialized_flows.add(f"pc_{related_device_id}")
def remove_initialized_flow(self, entry: ConfigEntry) -> None:
"""Remove a flow from the initialized flows."""
if entry.unique_id:
@@ -199,17 +225,22 @@ class DiscoveryManager:
async def perform_discovery(
self,
source_provider: Callable[[], Awaitable[list[_DiscoverySourceT]]],
source_creator: Callable[[_DiscoverySourceT], Awaitable[SourceEntity]],
source_provider: Callable[[], list[_DiscoverySourceT]],
source_creator: Callable[[_DiscoverySourceT], SourceEntity],
discovery_type: DiscoveryBy,
) -> None:
"""Generalized discovery procedure for entities and devices."""
for source in await source_provider():
library = await self._get_library()
ignored_domains = library.discovery_ignored_domains
for source in source_provider():
log_identifier = str(
getattr(source, "entity_id", getattr(source, "id", getattr(source, "entry_id", "unknown"))),
)
try:
source_entity = await source_creator(source)
if self._is_domain_ignored(source, ignored_domains):
_LOGGER.debug("%s: Integration domain is ignored, skipping discovery", log_identifier)
continue
source_entity = source_creator(source)
model_info = await self.extract_model_info_from_device_info(
source_entity.entity_entry or source_entity.device_entry,
)
@@ -235,13 +266,19 @@ class DiscoveryManager:
)
continue
self._init_entity_discovery(model_info, unique_id, source_entity, log_identifier, power_profiles, {})
except Exception as err: # noqa: BLE001
_LOGGER.error(
"%s: Error during %s discovery: %s",
await self._init_entity_discovery(
model_info,
unique_id,
source_entity,
log_identifier,
power_profiles,
{},
)
except Exception:
_LOGGER.exception(
"%s: Error during %s discovery",
log_identifier,
discovery_type,
err,
)
async def discover_entity(
@@ -256,12 +293,12 @@ class DiscoveryManager:
return await self.find_power_profiles(model_info, source_entity, discovery_type)
async def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity:
def create_entity_source(self, entity_entry: er.RegistryEntry) -> SourceEntity:
"""Create SourceEntity for an entity."""
return create_source_entity(entity_entry.entity_id, self.hass)
@staticmethod
async def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity:
def create_device_source(device_entry: dr.DeviceEntry) -> SourceEntity:
"""Create SourceEntity for a device."""
return SourceEntity(
object_id=device_entry.name_by_user or device_entry.name or "",
@@ -271,7 +308,7 @@ class DiscoveryManager:
device_entry=device_entry,
)
async def create_config_entry_source(self, config_entry: ConfigEntry) -> SourceEntity:
def create_config_entry_source(self, config_entry: ConfigEntry) -> SourceEntity:
"""Create a source representing all devices belonging to a config entry."""
device_entry = get_first_device_for_config_entry(self.hass, config_entry.entry_id)
return SourceEntity(
@@ -341,6 +378,23 @@ class DiscoveryManager:
return power_profiles
def _is_domain_ignored(self, source: _DiscoverySourceT, ignored_domains: set[str]) -> bool:
"""Return whether a discovery source belongs to a globally ignored integration domain."""
if not ignored_domains:
return False
if isinstance(source, er.RegistryEntry):
return source.platform in ignored_domains
if isinstance(source, ConfigEntry):
return source.domain in ignored_domains
config_entry_id = next(iter(source.config_entries), None)
if config_entry_id is None: # pragma: no cover
return False
config_entry = self.hass.config_entries.async_get_entry(config_entry_id)
return config_entry is not None and config_entry.domain in ignored_domains
async def init_wled_flow(self, model_info: ModelInfo, source_entity: SourceEntity) -> None:
"""Initialize the discovery flow for a WLED light."""
if DeviceType.LIGHT in self._exclude_device_types:
@@ -358,7 +412,7 @@ class DiscoveryManager:
)
return
self._init_entity_discovery(
await self._init_entity_discovery(
model_info,
unique_id,
source_entity,
@@ -379,7 +433,7 @@ class DiscoveryManager:
and not re.search("master|segment", str(entity_entry.entity_id), flags=re.IGNORECASE)
)
async def get_entities(self) -> list[er.RegistryEntry]:
def get_entities(self) -> list[er.RegistryEntry]:
"""Get all entities from entity registry which qualifies for discovery."""
def _check_already_configured(entity: er.RegistryEntry) -> bool:
@@ -409,7 +463,7 @@ class DiscoveryManager:
)
return get_filtered_entity_list(self.hass, NotFilter(entity_filter))
async def get_devices(self) -> list[dr.DeviceEntry]:
def get_devices(self) -> list[dr.DeviceEntry]:
"""Fetch device entries."""
return [
device
@@ -417,7 +471,7 @@ class DiscoveryManager:
if not is_composite_device_id(self.hass, device.id)
]
async def get_config_entries(self) -> list[ConfigEntry]:
def get_config_entries(self) -> list[ConfigEntry]:
"""Fetch config entries which have at least one non-composite device."""
config_entry_ids = {
config_entry_id
@@ -518,8 +572,7 @@ class DiscoveryManager:
return await self.get_model_information_from_device(device_entry)
@callback
def _init_entity_discovery(
async def _init_entity_discovery(
self,
model_info: ModelInfo,
unique_id: str,
@@ -532,6 +585,7 @@ class DiscoveryManager:
discovery_data: dict[str, Any] = {
CONF_ENTITY_ID: source_entity.entity_id,
DISCOVERY_INTEGRATION_NAME: await self._get_integration_name(source_entity),
DISCOVERY_SOURCE_ENTITY: source_entity,
CONF_UNIQUE_ID: unique_id,
}
@@ -564,6 +618,27 @@ class DiscoveryManager:
data=discovery_data,
)
async def _get_integration_name(self, source_entity: SourceEntity) -> str | None:
"""Return the display name of the integration which owns the discovery source."""
config_entry_id = source_entity.config_entry_id
if config_entry_id is None and source_entity.entity_entry:
config_entry_id = source_entity.entity_entry.config_entry_id
if config_entry_id is None and source_entity.device_entry:
config_entry_id = next(iter(get_config_entry_ids(source_entity.device_entry)), None)
if config_entry_id is None:
return None
config_entry = self.hass.config_entries.async_get_entry(config_entry_id)
if config_entry is None:
return None
try:
integration = await async_get_integration(self.hass, config_entry.domain)
except IntegrationNotFound:
_LOGGER.debug("Unable to resolve integration name for domain %s", config_entry.domain)
return None
return integration.name
@property
def status(self) -> DiscoveryStatus:
"""Get the discovery status"""
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Any
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.helpers import selector, translation
from homeassistant.helpers.device_registry import DeviceEntry
import voluptuous as vol
from custom_components.powercalc.const import (
@@ -20,7 +21,7 @@ from custom_components.powercalc.const import (
LIBRARY_URL,
CalculationStrategy,
)
from custom_components.powercalc.device_binding import get_devices_for_config_entry
from custom_components.powercalc.device_binding import get_devices_for_config_entry, get_related_devices
from custom_components.powercalc.discovery import (
get_power_profile_by_source_device,
get_power_profile_by_source_entity,
@@ -225,33 +226,51 @@ class LibraryFlow:
async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Ask which device should receive the entities created for a config-entry profile."""
assert self.flow.source_entity is not None
assert self.flow.source_entity.config_entry_id is not None
devices = get_devices_for_config_entry(self.flow.hass, self.flow.source_entity.config_entry_id)
return await self.flow.handle_form_step(
PowercalcFormStep(
step=Step.SELECT_DEVICE,
schema=vol.Schema(
{
vol.Required(CONF_DEVICE): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=device.id,
label=device.name_by_user or device.name or device.id,
)
for device in devices
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
),
schema=self.build_select_device_schema(),
next_step=Step.POST_LIBRARY,
),
user_input,
)
def build_select_device_schema(self) -> vol.Schema:
"""Build the schema to select the device the Powercalc entities should be linked to."""
discovery_by = self.flow.selected_profile.discovery_by if self.flow.selected_profile else DiscoveryBy.ENTITY
if discovery_by == DiscoveryBy.CONFIG_ENTRY:
# The entities can only be linked to one of the devices of the source config entry.
return vol.Schema(
{
vol.Required(CONF_DEVICE): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
selector.SelectOptionDict(
value=device.id,
label=device.name_by_user or device.name or device.id,
)
for device in self.get_selectable_devices()
],
mode=selector.SelectSelectorMode.DROPDOWN,
),
),
},
)
return vol.Schema({vol.Required(CONF_DEVICE): selector.DeviceSelector()})
def get_selectable_devices(self) -> list[DeviceEntry]:
"""Return the devices belonging to the same config entry as the source of the profile."""
config_entry_id = self.flow.source_entity.config_entry_id if self.flow.source_entity else None
if config_entry_id:
return get_devices_for_config_entry(self.flow.hass, config_entry_id)
# The options flow has no reference to the source config entry anymore,
# so resolve the candidates from the currently configured device instead.
device_id: str | None = self.flow.sensor_config.get(CONF_DEVICE)
return get_related_devices(self.flow.hass, device_id) if device_id else []
async def _async_next_strategy_step(self, profile: PowerProfile) -> ConfigFlowResult | None:
"""Return the next step needed to configure the calculation strategy, or None when nothing is left to ask."""
handled_steps = self.flow.handled_steps
@@ -641,6 +660,14 @@ class LibraryOptionsFlow(LibraryFlow):
super().__init__(flow)
self.flow: PowercalcOptionsFlow = flow
async def async_step_select_device(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Change the device the Powercalc entities are linked to."""
return await self.flow.async_handle_options_step(
user_input,
self.build_select_device_schema(),
Step.SELECT_DEVICE,
)
async def async_step_library_options(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
"""Handle the basic options flow."""
self.flow.is_library_flow = True
+1 -1
View File
@@ -22,5 +22,5 @@
"requirements": [
"numpy>=1.21.1"
],
"version": "v1.24.0"
"version": "v1.24.1"
}
@@ -52,6 +52,11 @@ class ProfileLibrary:
async def initialize(self) -> None:
await self._loader.initialize()
@property
def discovery_ignored_domains(self) -> set[str]:
"""Get integration domains globally excluded from discovery."""
return self._loader.get_discovery_ignored_domains()
@staticmethod
@singleton("powercalc_library")
async def factory(hass: HomeAssistant) -> ProfileLibrary:
@@ -15,6 +15,10 @@ class CompositeLoader(Loader):
for loader in self.loaders:
await loader.initialize()
def get_discovery_ignored_domains(self) -> set[str]:
"""Get all integration domains excluded by the combined libraries."""
return {domain for loader in self.loaders for domain in loader.get_discovery_ignored_domains()}
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
@@ -25,6 +25,10 @@ class LocalLoader(Loader):
if not self._is_custom_directory:
await self._hass.async_add_executor_job(self._load_custom_library)
def get_discovery_ignored_domains(self) -> set[str]:
"""Local profile directories do not provide global library metadata."""
return set()
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
@@ -7,6 +7,9 @@ class Loader(Protocol):
async def initialize(self) -> None:
"""Initialize the loader."""
def get_discovery_ignored_domains(self) -> set[str]:
"""Get integration domains excluded from discovery."""
async def get_manufacturer_listing(
self,
device_types: set[DeviceType] | None,
@@ -16,7 +16,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.loader import async_get_integration
from custom_components.powercalc.const import API_URL, BUILT_IN_LIBRARY_DIR, DOMAIN
from custom_components.powercalc.const import (
API_URL,
BUILT_IN_LIBRARY_DIR,
DOMAIN,
LIBRARY_DISCOVERY_IGNORED_DOMAINS,
)
from custom_components.powercalc.helpers import async_cache, clear_async_cache
from custom_components.powercalc.power_profile.error import LibraryLoadingError, ProfileDownloadError
from custom_components.powercalc.power_profile.loader.protocol import Loader
@@ -80,6 +85,10 @@ class RemoteLoader(Loader):
for manufacturer in manufacturers:
self._index_manufacturer(manufacturer, powercalc_version)
def get_discovery_ignored_domains(self) -> set[str]:
"""Get integration domains excluded from discovery by library metadata."""
return set(self.library_contents.get(LIBRARY_DISCOVERY_IGNORED_DOMAINS, []))
def _index_manufacturer(self, manufacturer: LibraryManufacturer, powercalc_version: AwesomeVersion) -> None:
"""Register a manufacturer, its aliases and all of its supported models in the lookup tables."""
manufacturer_name = str(manufacturer.get("dir_name"))
+2 -5
View File
@@ -538,11 +538,8 @@ async def handle_nested_entity(
),
)
entities_to_add.extend_items(child_entities)
except SensorConfigurationError as exception:
_LOGGER.error(
"Group state might be misbehaving because there was an error with an entity",
exc_info=exception,
)
except SensorConfigurationError:
_LOGGER.exception("Group state might be misbehaving because there was an error with an entity")
async def add_discovered_entities(
@@ -938,8 +938,8 @@ class PreviousStateStore:
instance.states[group] = {
entity_id: State.from_dict(json_state) for (entity_id, json_state) in entities.items()
}
except HomeAssistantError as exc: # pragma: no cover
_LOGGER.error("Error loading previous energy sensor states", exc_info=exc)
except HomeAssistantError: # pragma: no cover
_LOGGER.exception("Error loading previous energy sensor states")
instance.async_setup_dump()
@@ -983,8 +983,8 @@ class PreviousStateStore:
"""Save the current states to storage."""
try:
await self.store.async_save(self.states)
except HomeAssistantError as exc: # pragma: no cover
_LOGGER.error("Error saving current states", exc_info=exc)
except HomeAssistantError: # pragma: no cover
_LOGGER.exception("Error saving current states")
@callback
def async_setup_dump(self) -> None:
@@ -971,6 +971,7 @@
"playbook": "Možnosti playbooku",
"multi_switch": "Možnosti vícenásobného spínače",
"real_power": "Možnosti skutečného výkonu",
"select_device": "Zařízení",
"utility_meter_options": "Možnosti měřičů spotřeby",
"wled": "Možnosti WLED",
"cost_options": "Možnosti nákladů"
@@ -980,6 +981,16 @@
"title": "Možnosti knihovny",
"description": "Aktuálně je vybrán následující profil knihovny: \n výrobce: {manufacturer}\n model: {model}\n\nPokud chcete profil změnit, klikněte na Další."
},
"select_device": {
"data": {
"device": "Zařízení"
},
"data_description": {
"device": "Zařízení, ke kterému mají být entity Powercalc připojeny"
},
"description": "Vyberte zařízení, ke kterému má být senzor Powercalc připojen.",
"title": "Vyberte zařízení"
},
"linear": {
"title": "Lineární možnosti",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook-indstillinger",
"multi_switch": "Indstillinger for multikontakt",
"real_power": "Indstillinger for fysisk effekt",
"select_device": "Enhed",
"utility_meter_options": "Indstillinger for forbrugsmålere",
"wled": "WLED-indstillinger",
"cost_options": "Omkostningsindstillinger"
@@ -980,6 +981,16 @@
"title": "Biblioteksindstillinger",
"description": "I øjeblikket er følgende biblioteksprofil valgt: \n producent: {manufacturer}\n model: {model}\n\nHvis du vil ændre profilen, skal du klikke på næste."
},
"select_device": {
"data": {
"device": "Enhed"
},
"data_description": {
"device": "Den enhed, som Powercalc-entiteterne skal knyttes til"
},
"description": "Vælg den enhed, som Powercalc-sensoren skal knyttes til.",
"title": "Vælg enhed"
},
"linear": {
"title": "Lineære indstillinger",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook-Optionen",
"multi_switch": "Mehrfachschalter-Optionen",
"real_power": "Optionen für reale Leistung",
"select_device": "Gerät",
"utility_meter_options": "Verbrauchszähler-Optionen",
"wled": "WLED Einstellung",
"cost_options": "Kostenoptionen"
@@ -980,6 +981,16 @@
"title": "Bibliotheksoptionen",
"description": "Derzeit ist folgendes Bibliotheksprofil ausgewählt: \n Hersteller: {manufacturer}\n Modell: {model}\n\nWenn du das Profil ändern möchtest, klicke auf Weiter."
},
"select_device": {
"data": {
"device": "Gerät"
},
"data_description": {
"device": "Das Gerät, mit dem die Powercalc-Entitäten verknüpft werden sollen"
},
"description": "Wählen Sie das Gerät aus, mit dem der Powercalc-Sensor verknüpft werden soll.",
"title": "Gerät auswählen"
},
"linear": {
"title": "Lineare Optionen",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook options",
"multi_switch": "Multi switch options",
"real_power": "Real power options",
"select_device": "Device",
"utility_meter_options": "Utility meter options",
"wled": "WLED options",
"cost_options": "Cost options"
@@ -980,6 +981,16 @@
"title": "Library options",
"description": "Currently the following library profile is selected: \n manufacturer: {manufacturer}\n model: {model}\n\nIf you want to change the profile, click next."
},
"select_device": {
"data": {
"device": "Device"
},
"data_description": {
"device": "The device the Powercalc entities should be linked to"
},
"description": "Select the device to which the Powercalc sensor should be linked.",
"title": "Select device"
},
"linear": {
"title": "Linear options",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opciones de Reproducciones",
"multi_switch": "Opciones multi-interruptor",
"real_power": "Opciones de potencia real",
"select_device": "Dispositivo",
"utility_meter_options": "Opciones de contador eléctrico",
"wled": "Opciones de WLED",
"cost_options": "Opciones de costos"
@@ -980,6 +981,16 @@
"title": "Opciones de biblioteca",
"description": "Actualmente se ha seleccionado el siguiente perfil de biblioteca: \n fabricante: {manufacturer}\n modelo: {model}\n\nSi quieres cambiar el perfil, haz clic a continuación."
},
"select_device": {
"data": {
"device": "Dispositivo"
},
"data_description": {
"device": "El dispositivo al que se deben vincular las entidades de Powercalc"
},
"description": "Seleccione el dispositivo al que se debe vincular el sensor de Powercalc.",
"title": "Seleccionar dispositivo"
},
"linear": {
"title": "Opciones lineales",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbookin vaihtoehdot",
"multi_switch": "Useita kytkinvaihtoehtoja",
"real_power": "Todelliset tehovaihtoehdot",
"select_device": "Laite",
"utility_meter_options": "Kulutusmittarin asetukset",
"wled": "WLED vaihtoehdot",
"cost_options": "Kustannusvaihtoehdot"
@@ -980,6 +981,16 @@
"title": "Kirjaston vaihtoehdot",
"description": "Tällä hetkellä on valittuna seuraava kirjastoprofiili: \n valmistaja: {manufacturer}\n malli: {model}\n\nJos haluat vaihtaa profiilia, napsauta Seuraava."
},
"select_device": {
"data": {
"device": "Laite"
},
"data_description": {
"device": "Laite, johon Powercalc-entiteetit liitetään"
},
"description": "Valitse laite, johon Powercalc-anturi liitetään.",
"title": "Valitse laite"
},
"linear": {
"title": "Lineaariset vaihtoehdot",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Options du playbook",
"multi_switch": "Options du multi-interrupteur",
"real_power": "Options de puissance réelle",
"select_device": "Appareil",
"utility_meter_options": "Options des compteurs de services publics",
"wled": "Options WLED",
"cost_options": "Options de coût"
@@ -980,6 +981,16 @@
"title": "Options de bibliothèque",
"description": "Le profil de bibliothèque suivant est actuellement sélectionné : \n fabricant : {manufacturer}\n modèle : {model}\n\nSi vous souhaitez changer de profil, cliquez sur Suivant."
},
"select_device": {
"data": {
"device": "Appareil"
},
"data_description": {
"device": "Lappareil auquel les entités Powercalc doivent être associées"
},
"description": "Sélectionnez lappareil auquel le capteur Powercalc doit être associé.",
"title": "Sélectionner un appareil"
},
"linear": {
"title": "Options linéaires",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook beállításai",
"multi_switch": "Többkapcsolós beállítások",
"real_power": "Valós teljesítmény beállításai",
"select_device": "Eszköz",
"utility_meter_options": "Közüzemi mérő beállításai",
"wled": "WLED beállításai",
"cost_options": "Költségbeállítások"
@@ -980,6 +981,16 @@
"title": "Könyvtári beállítások",
"description": "Jelenleg a következő könyvtári profil van kiválasztva: \n gyártó: {manufacturer}\n modell: {model}\n\nHa módosítani szeretné a profilt, kattintson a Tovább gombra."
},
"select_device": {
"data": {
"device": "Eszköz"
},
"data_description": {
"device": "Az eszköz, amelyhez a Powercalc-entitásokat hozzá kell rendelni"
},
"description": "Válassza ki azt az eszközt, amelyhez a Powercalc-érzékelőt hozzá kell rendelni.",
"title": "Eszköz kiválasztása"
},
"linear": {
"title": "Lineáris beállítások",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opzioni del playbook",
"multi_switch": "Opzioni multiinterruttore",
"real_power": "Opzioni di potenza reale",
"select_device": "Dispositivo",
"utility_meter_options": "Opzioni del contatore di utilità",
"wled": "Opzioni WLED",
"cost_options": "Opzioni di costo"
@@ -980,6 +981,16 @@
"title": "Opzioni della libreria",
"description": "Attualmente è selezionato il seguente profilo libreria: \n produttore: {manufacturer}\n modello: {model}\n\nSe desideri modificare il profilo, fai clic su Avanti."
},
"select_device": {
"data": {
"device": "Dispositivo"
},
"data_description": {
"device": "Il dispositivo a cui devono essere collegate le entità Powercalc"
},
"description": "Seleziona il dispositivo a cui deve essere collegato il sensore Powercalc.",
"title": "Seleziona dispositivo"
},
"linear": {
"title": "Opzioni lineari",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook-alternativer",
"multi_switch": "Alternativer for flere brytere",
"real_power": "Innstillinger for målt effekt",
"select_device": "Enhet",
"utility_meter_options": "Innstillinger for forbruksmåler",
"wled": "WLED-innstillinger",
"cost_options": "Kostnadsalternativer"
@@ -980,6 +981,16 @@
"title": "Alternativer for bibliotek",
"description": "For øyeblikket er følgende bibliotekprofil valgt: \n produsent: {manufacturer}\n modell: {model}\n\nHvis du vil endre profilen, klikker du på neste."
},
"select_device": {
"data": {
"device": "Enhet"
},
"data_description": {
"device": "Enheten Powercalc-entitetene skal knyttes til"
},
"description": "Velg enheten som Powercalc-sensoren skal knyttes til.",
"title": "Velg enhet"
},
"linear": {
"title": "Lineære alternativer",
"data": {
@@ -10,7 +10,7 @@
"already_configured": "Sensor is reeds geconfigureerd, specifieer een uniek ID"
},
"error": {
"cost_price_mandatory": "U moet een energieprijs of een energieprijssensor aanleveren",
"cost_price_mandatory": "U moet een energieprijs of een sensor van de energieprijs leveren",
"daily_energy_mandatory": "Je moet minimaal waarde of waarde template opgeven",
"entity_mandatory": "Je moet verplicht een entiteit opgeven voor iedere andere strategie dan playbook",
"fixed_mandatory": "Je dient minimaal een van de volgende velden te definiëren: Vermogen, Vermogen template of Vermogen per status",
@@ -971,6 +971,7 @@
"playbook": "Playbook opties",
"multi_switch": "Multi switch opties",
"real_power": "Opties voor werkelijk vermogen",
"select_device": "Apparaat",
"utility_meter_options": "Nutsmeter opties",
"wled": "WLED opties",
"cost_options": "Kosten opties"
@@ -980,6 +981,16 @@
"title": "Bibliotheek opties",
"description": "Momenteel is het huidige profiel geselecteerd: \nfabrikant: {manufacturer}\nmodel: {model}\n\nAls u het profiel wilt wijzigen, klikt u op volgende."
},
"select_device": {
"data": {
"device": "Apparaat"
},
"data_description": {
"device": "Het apparaat waaraan de Powercalc-entiteiten moeten worden gekoppeld"
},
"description": "Selecteer het apparaat waaraan de Powercalc-sensor moet worden gekoppeld.",
"title": "Apparaat selecteren"
},
"linear": {
"title": "Lineaire opties",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opcje playbook",
"multi_switch": "Opcje wieloprzełącznika",
"real_power": "Opcje rzeczywistej mocy",
"select_device": "Urządzenie",
"utility_meter_options": "Opcje licznika mediów",
"wled": "Opcje WLED",
"cost_options": "Opcje kosztów"
@@ -980,6 +981,16 @@
"title": "Opcje biblioteki",
"description": "Obecnie wybrany jest następujący profil biblioteki: \n producent: {manufacturer}\n model: {model}\n\nJeśli chcesz zmienić profil, kliknij dalej."
},
"select_device": {
"data": {
"device": "Urządzenie"
},
"data_description": {
"device": "Urządzenie, z którym mają zostać powiązane encje Powercalc"
},
"description": "Wybierz urządzenie, z którym ma zostać powiązany czujnik Powercalc.",
"title": "Wybierz urządzenie"
},
"linear": {
"title": "Opcje liniowe",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opções de roteiro",
"multi_switch": "Opções de multi-interruptor",
"real_power": "Opções de potência real",
"select_device": "Dispositivo",
"utility_meter_options": "Opções do medidor de utilidade",
"wled": "Opções de WLED",
"cost_options": "Opções de custo"
@@ -980,6 +981,16 @@
"title": "Opções da biblioteca",
"description": "Atualmente o seguinte perfil de biblioteca esta selecionado: \n fabricante {manufacturer}\n modelo: {model}\n\nSe você deseja alterar o perfil, clique em próximo."
},
"select_device": {
"data": {
"device": "Dispositivo"
},
"data_description": {
"device": "O dispositivo ao qual as entidades do Powercalc devem ser vinculadas"
},
"description": "Selecione o dispositivo ao qual o sensor do Powercalc deve ser vinculado.",
"title": "Selecionar dispositivo"
},
"linear": {
"title": "Opções linear",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opções de sequência",
"multi_switch": "Opções de interruptor múltiplo",
"real_power": "Opções de potência real",
"select_device": "Dispositivo",
"utility_meter_options": "Opções de contador de serviços",
"wled": "Opções WLED",
"cost_options": "Opções de custo"
@@ -980,6 +981,16 @@
"title": "Opções da biblioteca",
"description": "Atualmente está selecionado o seguinte perfil da biblioteca: \n fabricante: {manufacturer}\n modelo: {model}\n\nSe quiser alterar o perfil, clique em seguinte."
},
"select_device": {
"data": {
"device": "Dispositivo"
},
"data_description": {
"device": "O dispositivo ao qual as entidades do Powercalc devem ser associadas"
},
"description": "Selecione o dispositivo ao qual o sensor do Powercalc deve ser associado.",
"title": "Selecionar dispositivo"
},
"linear": {
"title": "Opções lineares",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Opțiuni de playbook",
"multi_switch": "Opțiuni de comutare multiplă",
"real_power": "Opțiuni de putere reală",
"select_device": "Dispozitiv",
"utility_meter_options": "Opțiuni de contor de utilitate",
"wled": "Opțiuni WLED",
"cost_options": "Opțiuni de cost"
@@ -980,6 +981,16 @@
"title": "Opțiuni de bibliotecă",
"description": "În prezent este selectat următorul profil de bibliotecă: \n producator: {manufacturer}\n model: {model}\n\nDacă doriți să schimbați profilul, faceți clic pe următorul."
},
"select_device": {
"data": {
"device": "Dispozitiv"
},
"data_description": {
"device": "Dispozitivul la care trebuie asociate entitățile Powercalc"
},
"description": "Selectați dispozitivul la care trebuie asociat senzorul Powercalc.",
"title": "Selectați dispozitivul"
},
"linear": {
"title": "Opțiuni liniare",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Параметры сценариев",
"multi_switch": "Параметры мультивыключателя",
"real_power": "Параметры реальной мощности",
"select_device": "Устройство",
"utility_meter_options": "Параметры счётчиков",
"wled": "Параметры WLED",
"cost_options": "Варианты стоимости"
@@ -980,6 +981,16 @@
"title": "Параметры библиотеки",
"description": "В настоящее время выбран следующий профиль библиотеки: \n производитель: {manufacturer}\n модель: {model}\n\nЕсли хотите изменить профиль, нажмите далее."
},
"select_device": {
"data": {
"device": "Устройство"
},
"data_description": {
"device": "Устройство, к которому следует привязать сущности Powercalc"
},
"description": "Выберите устройство, к которому следует привязать датчик Powercalc.",
"title": "Выберите устройство"
},
"linear": {
"title": "Линейные параметры",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Možnosti playbooku",
"multi_switch": "Možnosti viacerých prepínačov",
"real_power": "Skutočné možnosti napájania",
"select_device": "Zariadenie",
"utility_meter_options": "Možnosti elektromera",
"wled": "WLED možnosti",
"cost_options": "Možnosti nákladov"
@@ -980,6 +981,16 @@
"title": "Možnosti knižnice",
"description": "Momentálne je vybratý nasledujúci profil knižnice: \n výrobca: {manufacturer}\n model: {model}\n\nAk chcete profil zmeniť, kliknite na tlačidlo Ďalej."
},
"select_device": {
"data": {
"device": "Zariadenie"
},
"data_description": {
"device": "Zariadenie, ku ktorému majú byť priradené entity Powercalc"
},
"description": "Vyberte zariadenie, ku ktorému má byť priradený senzor Powercalc.",
"title": "Vyberte zariadenie"
},
"linear": {
"title": "lineárne možnosti",
"data": {
@@ -971,6 +971,7 @@
"playbook": "Playbook-alternativ",
"multi_switch": "Alternativ för flera switchar",
"real_power": "Verkliga kraftalternativ",
"select_device": "Enhet",
"utility_meter_options": "Alternativ för verktygsmätare",
"wled": "WLED alternativ",
"cost_options": "Kostnadsalternativ"
@@ -980,6 +981,16 @@
"title": "Biblioteksalternativ",
"description": "För närvarande är följande biblioteksprofil vald: \n tillverkare: {manufacturer}\n modell: {model}\n\nOm du vill ändra profilen klickar du på nästa."
},
"select_device": {
"data": {
"device": "Enhet"
},
"data_description": {
"device": "Enheten som Powercalc-entiteterna ska länkas till"
},
"description": "Välj enheten som Powercalc-sensorn ska länkas till.",
"title": "Välj enhet"
},
"linear": {
"title": "Linjära alternativ",
"data": {
@@ -971,6 +971,7 @@
"playbook": "运行方案选项",
"multi_switch": "多路开关选项",
"real_power": "实际功率选项",
"select_device": "设备",
"utility_meter_options": "公用事业计量表选项",
"wled": "WLED 选项",
"cost_options": "费用选项"
@@ -980,6 +981,16 @@
"title": "配置文件库选项",
"description": "当前选择了以下配置文件库中的配置文件:\n 制造商:{manufacturer}\n 型号:{model}\n\n如需更改配置文件,请点击“下一步”。"
},
"select_device": {
"data": {
"device": "设备"
},
"data_description": {
"device": "Powercalc 实体应关联到的设备"
},
"description": "选择 Powercalc 传感器应关联到的设备。",
"title": "选择设备"
},
"linear": {
"title": "线性选项",
"data": {
+1
View File
@@ -34,6 +34,7 @@ from .token_manager import TokenManager
PLATFORMS = [
"light",
"switch",
"fan",
"lock",
"climate",
"alarm_control_panel",
+1
View File
@@ -15,6 +15,7 @@ LOCK_UPDATED = f"{DOMAIN}.lock_updated"
CAMERA_UPDATED = f"{DOMAIN}.camera_updated"
LIGHT_UPDATED = f"{DOMAIN}.light_updated"
COVER_UPDATED = f"{DOMAIN}.cover_updated"
AIR_PURIFIER_UPDATED = f"{DOMAIN}.air_purifier_updated"
RESET_BUTTON_PRESSED = f"{DOMAIN}.reset_button_pressed"
# EVENT NAMES
WYZE_CAMERA_EVENT = "wyze_camera_event"
+2 -2
View File
@@ -11,8 +11,8 @@
"issue_tracker": "https://github.com/SecKatie/ha-wyzeapi/issues",
"loggers": ["custom_components.wyzeapi"],
"requirements": [
"wyzeapy>=0.5.33,<0.6",
"wyzeapy>=0.6.1,<0.7",
"websockets"
],
"version": "0.1.38"
"version": "0.1.39"
}
+145
View File
@@ -7,6 +7,7 @@ import logging
from typing import Any
from wyzeapy import Wyzeapy
from wyzeapy.services.air_purifier_service import AirPurifier
from wyzeapy.services.camera_service import Camera
from wyzeapy.services.irrigation_service import Irrigation, IrrigationService
from wyzeapy.services.lock_service import Lock
@@ -36,6 +37,7 @@ from homeassistant.helpers.event import (
)
from .const import (
AIR_PURIFIER_UPDATED,
CAMERA_UPDATED,
CONF_CLIENT,
DOMAIN,
@@ -71,6 +73,7 @@ async def async_setup_entry(
camera_service = await client.camera_service
switch_usage_service = await client.switch_usage_service
irrigation_service = await client.irrigation_service
air_purifier_service = await client.air_purifier_service
locks = await lock_service.get_locks()
sensors = []
@@ -95,6 +98,11 @@ async def async_setup_entry(
sensors.append(WyzePlugEnergySensor(plug, switch_usage_service))
sensors.append(WyzePlugDailyEnergySensor(plug))
air_purifiers = await air_purifier_service.get_air_purifiers()
for air_purifier in air_purifiers:
sensors.append(WyzeAirPurifierAQISensor(air_purifier))
sensors.append(WyzeAirPurifierHourlyMaxAQISensor(air_purifier))
# Get all irrigation devices
irrigation_devices = await irrigation_service.get_irrigations()
@@ -624,3 +632,140 @@ class WyzeIrrigationSSID(WyzeIrrigationBaseSensor):
def native_value(self) -> str:
"""Return the SSID."""
return self._device.ssid
class WyzeAirPurifierAirQualitySensor(SensorEntity):
"""Base class for Wyze Air Purifier air quality sensors."""
_attr_attribution = ATTRIBUTION
_attr_device_class = SensorDeviceClass.AQI
_attr_has_entity_name = True
_attr_should_poll = False
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 0
def __init__(
self,
air_purifier: AirPurifier,
) -> None:
"""Initialize the AQI sensor."""
self._air_purifier = air_purifier
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this entity."""
device_info = DeviceInfo(
identifiers={(DOMAIN, self._air_purifier.mac)},
name=self._air_purifier.nickname,
manufacturer="WyzeLabs",
model=self._air_purifier.product_model,
)
if self._air_purifier.app_version:
device_info["sw_version"] = self._air_purifier.app_version
if self._air_purifier.sn:
device_info["serial_number"] = self._air_purifier.sn
if self._air_purifier.wifi_mac:
device_info["connections"] = {
(dr.CONNECTION_NETWORK_MAC, self._air_purifier.wifi_mac)
}
return device_info
@property
def available(self) -> bool:
"""Return the connection status of this sensor."""
return self._air_purifier.available
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device attributes of the entity."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
"device model": self._air_purifier.product_model,
}
@callback
def handle_air_purifier_update(self, air_purifier: AirPurifier) -> None:
"""Handle air purifier updates."""
self._air_purifier = air_purifier
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Add listener on startup."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{AIR_PURIFIER_UPDATED}-{self._air_purifier.mac}",
self.handle_air_purifier_update,
)
)
class WyzeAirPurifierAQISensor(WyzeAirPurifierAirQualitySensor):
"""Representation of a Wyze Air Purifier current AQI sensor."""
_attr_name = "Current AQI"
def __init__(
self,
air_purifier: AirPurifier,
) -> None:
"""Initialize the current AQI sensor."""
super().__init__(air_purifier)
self._attr_unique_id = f"{self._air_purifier.mac}-aqi"
@property
def native_value(self) -> int | None:
"""Return the current AQI value."""
return self._air_purifier.aqi
class WyzeAirPurifierHourlyMaxAQISensor(WyzeAirPurifierAirQualitySensor):
"""Representation of a Wyze Air Purifier hourly max AQI sensor."""
_attr_name = "Hourly Max AQI"
def __init__(
self,
air_purifier: AirPurifier,
) -> None:
"""Initialize the hourly max AQI sensor."""
super().__init__(air_purifier)
self._attr_unique_id = f"{self._air_purifier.mac}-hourly-max-aqi"
@property
def native_value(self) -> int | None:
"""Return the hourly max AQI value."""
return self._air_purifier.max_hourly_aqi
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device attributes of the entity."""
attributes = super().extra_state_attributes
attributes.update(
{
"hour_start": self._timestamp_attribute(
self._air_purifier.max_hourly_aqi_start_time
),
"hour_end": self._timestamp_attribute(
self._air_purifier.max_hourly_aqi_start_time,
offset=datetime.timedelta(hours=1),
),
"sampled_until": self._timestamp_attribute(
self._air_purifier.max_hourly_aqi_end_time
),
}
)
return attributes
@staticmethod
def _timestamp_attribute(
timestamp: int | None, offset: datetime.timedelta | None = None
) -> str | None:
"""Return an ISO formatted timestamp attribute."""
if timestamp is None:
return None
value = datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
if offset is not None:
value += offset
return value.isoformat()