Compare commits

..

1 Commits

Author SHA1 Message Date
Stefan Agner bbbcc0eba4 Harden backup tar extraction with Python tar_filter
Use Python's built-in tarfile tar filter instead of the no-op
fully_trusted filter combined with securetar.secure_path. The tar
filter validates linkname targets in addition to member names,
preventing extraction through a symlink whose linkname points outside
the destination directory.

Restore now aborts on the first rejected member instead of silently
skipping it, surfacing tampered backups via .HA_RESTORE_RESULT rather
than producing a partial restore.

Mirrors home-assistant/supervisor#6559.
2026-05-26 12:48:13 +02:00
150 changed files with 1356 additions and 16075 deletions
Generated
-2
View File
@@ -2056,8 +2056,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/yi/ @bachya
/homeassistant/components/yolink/ @matrixd2
/tests/components/yolink/ @matrixd2
/homeassistant/components/yoto/ @cdnninja @piitaya
/tests/components/yoto/ @cdnninja @piitaya
/homeassistant/components/youless/ @gjong
/tests/components/youless/ @gjong
/homeassistant/components/youtube/ @joostlek
+2 -4
View File
@@ -92,8 +92,7 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
filter="tar",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -119,8 +118,7 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf),
filter="fully_trusted",
filter="tar",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.8.0"]
"requirements": ["aioamazondevices==13.7.0"]
}
+4 -44
View File
@@ -1,6 +1,5 @@
"""Light platform for Avea."""
from collections.abc import Callable
from contextlib import suppress
import logging
from typing import Any
@@ -20,7 +19,6 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -29,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import AveaConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
@@ -44,13 +42,6 @@ def _normalize_name(name: str | None) -> str | None:
return name
def _read_device_info_value(read: Callable[[], str | None]) -> str | None:
"""Read a device information value from an Avea bulb."""
with suppress(*UPDATE_EXCEPTIONS):
return _normalize_name(read())
return None
def _ha_brightness_to_avea(brightness: int) -> int:
"""Convert Home Assistant brightness to Avea brightness."""
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
@@ -105,8 +96,7 @@ async def async_setup_entry(
) -> None:
"""Set up the Avea light platform."""
async_add_entities(
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
update_before_add=True,
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
)
@@ -190,42 +180,14 @@ class AveaLight(LightEntity):
"""Representation of an Avea."""
_attr_color_mode = ColorMode.HS
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb, address: str) -> None:
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
"""Initialize an AveaLight."""
self._light = light
self._attr_unique_id = address
self._attr_name = entry_title
self._attr_brightness = light.brightness
self._last_brightness = 255
self._device_info_updated = False
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_BLUETOOTH, address)},
model=MODEL,
)
def _update_device_info(self) -> None:
"""Fetch device information from the Avea bulb."""
device_info = self._attr_device_info
assert device_info is not None
manufacturer = _read_device_info_value(self._light.get_manufacturer_name)
hardware_revision = _read_device_info_value(self._light.get_hardware_revision)
firmware_version = _read_device_info_value(self._light.get_fw_version)
serial_number = _read_device_info_value(self._light.get_serial_number)
if manufacturer:
device_info["manufacturer"] = manufacturer
if hardware_revision:
device_info["hw_version"] = hardware_revision
if firmware_version:
device_info["sw_version"] = firmware_version
if serial_number:
device_info["serial_number"] = serial_number
self._device_info_updated = True
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@@ -252,8 +214,6 @@ class AveaLight(LightEntity):
connected = self._light.connect()
try:
if not self._device_info_updated:
self._update_device_info()
brightness = self._light.get_brightness()
rgb_color = self._light.get_rgb()
finally:
@@ -32,7 +32,6 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
PARALLEL_UPDATES = 0
+3 -31
View File
@@ -3,7 +3,7 @@
from typing import Any
import blebox_uniapi.cover
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
from blebox_uniapi.cover import BleboxCoverState
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -25,19 +25,6 @@ BLEBOX_TO_COVER_DEVICE_CLASSES = {
"shutter": CoverDeviceClass.SHUTTER,
}
UNIFIED_COVER_TYPE_TO_DEVICE_CLASS = {
UnifiedCoverType.AWNING: CoverDeviceClass.AWNING,
UnifiedCoverType.BLIND: CoverDeviceClass.BLIND,
UnifiedCoverType.CURTAIN: CoverDeviceClass.CURTAIN,
UnifiedCoverType.DAMPER: CoverDeviceClass.DAMPER,
UnifiedCoverType.DOOR: CoverDeviceClass.DOOR,
UnifiedCoverType.GARAGE: CoverDeviceClass.GARAGE,
UnifiedCoverType.GATE: CoverDeviceClass.GATE,
UnifiedCoverType.SHADE: CoverDeviceClass.SHADE,
UnifiedCoverType.SHUTTER: CoverDeviceClass.SHUTTER,
UnifiedCoverType.WINDOW: CoverDeviceClass.WINDOW,
}
BLEBOX_TO_HASS_COVER_STATES = {
None: None,
# all blebox covers
@@ -72,6 +59,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
def __init__(self, feature: blebox_uniapi.cover.Cover) -> None:
"""Initialize a BleBox cover feature."""
super().__init__(feature)
self._attr_device_class = BLEBOX_TO_COVER_DEVICE_CLASSES[feature.device_class]
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
@@ -88,21 +76,6 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
| CoverEntityFeature.CLOSE_TILT
)
if feature.tilt_only:
self._attr_supported_features &= ~(
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
@property
def device_class(self) -> CoverDeviceClass | None:
"""Return the device class based on cover type when available."""
if (cover_type := self._feature.cover_type) is not None:
return UNIFIED_COVER_TYPE_TO_DEVICE_CLASS[cover_type]
return BLEBOX_TO_COVER_DEVICE_CLASSES[self._feature.device_class]
@property
def current_cover_position(self) -> int | None:
"""Return the current cover position."""
@@ -145,8 +118,7 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Fully open the cover tilt."""
position = 50 if self._feature.is_tilt_180 else 0
await self._feature.async_set_tilt_position(position)
await self._feature.async_set_tilt_position(0)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Fully close the cover tilt."""
-141
View File
@@ -1,141 +0,0 @@
"""BleBox update entities implementation."""
from datetime import timedelta
from typing import Any, Final
from blebox_uniapi.error import ConnectionError as BleBoxConnectionError, Error
import blebox_uniapi.update
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
SCAN_INTERVAL = timedelta(hours=1)
_POLL_INTERVAL_SECONDS: Final = 10
_MAX_POLL_ATTEMPTS: Final = 30
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox update entry."""
entities = [
BleBoxUpdateEntity(feature)
for feature in config_entry.runtime_data.features.get("updates", [])
]
async_add_entities(entities, True)
class BleBoxUpdateEntity(BleBoxEntity[blebox_uniapi.update.Update], UpdateEntity):
"""Representation of BleBox updates."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
def __init__(self, feature: blebox_uniapi.update.Update) -> None:
"""Initialize the update entity."""
super().__init__(feature)
self._in_progress_old_version: str | None = None
self._poll_cancel: CALLBACK_TYPE | None = None
self._poll_attempts: int = 0
@property
def in_progress(self) -> bool:
"""Return True while the device hasn't yet rebooted to the new firmware."""
return (
self._in_progress_old_version is not None
and self._in_progress_old_version == self._feature.installed_version
)
def _sync_sw_version(self) -> None:
"""Sync installed firmware version to the device registry."""
if self.device_entry:
dr.async_get(self.hass).async_update_device(
self.device_entry.id,
sw_version=self._feature.installed_version,
)
async def async_update(self) -> None:
"""Update state and refresh sw_version in device registry."""
try:
await self._feature.async_update()
except Error as ex:
raise HomeAssistantError(ex) from ex
self._sync_sw_version()
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
return self._feature.installed_version
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
return self._feature.latest_version
def _cancel_poll(self) -> None:
if self._poll_cancel is not None:
self._poll_cancel()
self._poll_cancel = None
def _reset_progress(self) -> None:
self._in_progress_old_version = None
self._poll_attempts = 0
self.async_write_ha_state()
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
self._cancel_poll()
self._in_progress_old_version = self._feature.installed_version
self._poll_attempts = 0
self.async_write_ha_state()
try:
await self._feature.async_install()
except Error as ex:
self._reset_progress()
raise HomeAssistantError(ex) from ex
self._poll_cancel = async_call_later(
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
)
async def async_will_remove_from_hass(self) -> None:
"""Cancel any pending poll timer when the entity is removed."""
self._cancel_poll()
async def _poll_until_updated(self, _now: Any) -> None:
"""Poll device until the installed version changes after OTA reboot."""
self._poll_cancel = None
self._poll_attempts += 1
try:
await self._feature.async_update()
except BleBoxConnectionError:
pass
except Error:
self._reset_progress()
return
else:
self._sync_sw_version()
if self.in_progress and self._poll_attempts < _MAX_POLL_ATTEMPTS:
self._poll_cancel = async_call_later(
self.hass, _POLL_INTERVAL_SECONDS, self._poll_until_updated
)
else:
self._reset_progress()
-1
View File
@@ -17,7 +17,6 @@ BTHOME_BLE_EVENT: Final = "bthome_ble_event"
EVENT_CLASS_BUTTON: Final = "button"
EVENT_CLASS_DIMMER: Final = "dimmer"
EVENT_CLASS_COMMAND: Final = "command"
CONF_EVENT_CLASS: Final = "event_class"
CONF_EVENT_PROPERTIES: Final = "event_properties"
@@ -28,7 +28,6 @@ from .const import (
DOMAIN,
EVENT_CLASS,
EVENT_CLASS_BUTTON,
EVENT_CLASS_COMMAND,
EVENT_CLASS_DIMMER,
EVENT_TYPE,
)
@@ -44,7 +43,6 @@ EVENT_TYPES_BY_EVENT_CLASS = {
"hold_press",
},
EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"},
EVENT_CLASS_COMMAND: {"off", "on", "toggle", "step_up", "step_down"},
}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
-6
View File
@@ -16,7 +16,6 @@ from . import format_discovered_event_class, format_event_dispatcher_name
from .const import (
DOMAIN,
EVENT_CLASS_BUTTON,
EVENT_CLASS_COMMAND,
EVENT_CLASS_DIMMER,
EVENT_PROPERTIES,
EVENT_TYPE,
@@ -44,11 +43,6 @@ DESCRIPTIONS_BY_EVENT_CLASS = {
translation_key="dimmer",
event_types=["rotate_left", "rotate_right"],
),
EVENT_CLASS_COMMAND: EventEntityDescription(
key=EVENT_CLASS_COMMAND,
translation_key="command",
event_types=["off", "on", "toggle", "step_up", "step_down"],
),
}
@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.23.2"]
"requirements": ["bthome-ble==3.17.0"]
}
-12
View File
@@ -192,12 +192,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
# Light level (-)
(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.LIGHT_LEVEL),
state_class=SensorStateClass.MEASUREMENT,
translation_key="light_level",
),
# Mass sensor (kg)
(BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}",
@@ -293,12 +287,6 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
translation_key="rotational_speed",
),
# Settings revision (-)
(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.SETTINGS_REVISION),
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="settings_revision",
),
# Signal Strength (RSSI) (dB)
(
BTHomeSensorDeviceClass.SIGNAL_STRENGTH,
@@ -36,19 +36,13 @@
"long_double_press": "Long Double Press",
"long_press": "Long Press",
"long_triple_press": "Long Triple Press",
"off": "Off",
"on": "On",
"press": "Press",
"rotate_left": "Rotate Left",
"rotate_right": "Rotate Right",
"step_down": "Step Down",
"step_up": "Step Up",
"toggle": "Toggle",
"triple_press": "Triple Press"
},
"trigger_type": {
"button": "Button \"{subtype}\"",
"command": "Command \"{subtype}\"",
"dimmer": "Dimmer \"{subtype}\""
}
},
@@ -74,19 +68,6 @@
}
}
},
"command": {
"state_attributes": {
"event_type": {
"state": {
"off": "Off",
"on": "On",
"step_down": "Step down",
"step_up": "Step up",
"toggle": "Toggle"
}
}
}
},
"dimmer": {
"state_attributes": {
"event_type": {
@@ -117,9 +98,6 @@
"gyroscope": {
"name": "Gyroscope"
},
"light_level": {
"name": "Light level"
},
"packet_id": {
"name": "Packet ID"
},
@@ -132,9 +110,6 @@
"rotational_speed": {
"name": "Rotational speed"
},
"settings_revision": {
"name": "Settings revision"
},
"text": {
"name": "Text"
},
@@ -3,14 +3,23 @@
import asyncio
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .config_entry import ( # noqa: F401
DATA_COMPONENT,
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
TrackerEntityDescription,
async_setup_entry,
async_unload_entry,
)
from .const import ( # noqa: F401
ATTR_ATTRIBUTES,
ATTR_BATTERY,
@@ -36,14 +45,6 @@ from .const import ( # noqa: F401
SCAN_INTERVAL,
SourceType,
)
from .entity import ( # noqa: F401
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
ScannerEntityDescription,
TrackerEntity,
TrackerEntityDescription,
)
from .legacy import ( # noqa: F401
PLATFORM_SCHEMA,
PLATFORM_SCHEMA_BASE,
@@ -59,8 +60,6 @@ from .legacy import ( # noqa: F401
see,
)
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return the state if any or a specified device is home."""
@@ -109,23 +108,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
eager_start=True,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an entry."""
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
if component is not None:
return await component.async_setup_entry(entry)
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass
)
component.register_shutdown()
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@@ -1,45 +1,520 @@
"""Code to set up a device tracker platform using a config entry."""
from functools import partial
import asyncio
from typing import Any, final
from homeassistant.helpers.deprecation import (
DeprecatedAlias,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.typing import StateType
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
from . import (
BaseTrackerEntity as _BaseTrackerEntity,
ScannerEntity as _ScannerEntity,
SourceType as _SourceType,
TrackerEntity as _TrackerEntity,
TrackerEntityDescription as _TrackerEntityDescription,
)
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
_DEPRECATED_TrackerEntity = DeprecatedAlias(
_TrackerEntity, "homeassistant.components.device_tracker.TrackerEntity", "2027.6"
)
_DEPRECATED_ScannerEntity = DeprecatedAlias(
_ScannerEntity, "homeassistant.components.device_tracker.ScannerEntity", "2027.6"
)
_DEPRECATED_BaseTrackerEntity = DeprecatedAlias(
_BaseTrackerEntity,
"homeassistant.components.device_tracker.BaseTrackerEntity",
"2027.6",
)
_DEPRECATED_TrackerEntityDescription = DeprecatedAlias(
_TrackerEntityDescription,
"homeassistant.components.device_tracker.TrackerEntityDescription",
"2027.6",
)
_DEPRECATED_SourceType = DeprecatedAlias(
_SourceType, "homeassistant.components.device_tracker.SourceType", "2027.6"
)
# mypy: disallow-any-generics
# These can be removed if no deprecated aliases are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
)
__all__ = all_with_deprecated_constants(globals())
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an entry."""
component: EntityComponent[BaseTrackerEntity] | None = hass.data.get(DOMAIN)
if component is not None:
return await component.async_setup_entry(entry)
component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity](
LOGGER, DOMAIN, hass
)
component.register_shutdown()
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def _async_connected_device_registered(
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
) -> None:
"""Register a newly seen connected device.
This is currently used by the dhcp integration
to listen for newly registered connected devices
for discovery.
"""
async_dispatcher_send(
hass,
CONNECTED_DEVICE_REGISTERED,
{
ATTR_IP: ip_address,
ATTR_MAC: mac,
ATTR_HOST_NAME: hostname,
},
)
@callback
def _async_register_mac(
hass: HomeAssistant,
domain: str,
mac: str,
unique_id: str,
) -> None:
"""Register a mac address with a unique ID."""
mac = dr.format_mac(mac)
if DATA_KEY in hass.data:
hass.data[DATA_KEY][mac] = (domain, unique_id)
return
# Setup listening.
# dict mapping mac -> partial unique ID
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
@callback
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
"""Enable the online status entity for the mac of a newly created device."""
# Only for new devices
if ev.data["action"] != "create":
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
if device_entry is None:
# This should not happen, since the device was just created.
return
# Check if device has a mac
mac = None
for conn in device_entry.connections:
if conn[0] == dr.CONNECTION_NETWORK_MAC:
mac = conn[1]
break
if mac is None:
return
# Check if we have an entity for this mac
if (unique_id := data.get(mac)) is None:
return
ent_reg = er.async_get(hass)
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
return
entity_entry = ent_reg.entities[entity_id]
# Make sure entity has a config entry and was disabled by the
# default disable logic in the integration and new entities
# are allowed to be added.
if (
entity_entry.config_entry_id is None
or (
(
config_entry := hass.config_entries.async_get_entry(
entity_entry.config_entry_id
)
)
is not None
and config_entry.pref_disable_new_entities
)
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
):
return
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
class BaseTrackerEntity(Entity):
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
if hasattr(self, "_attr_source_type"):
return self._attr_source_type
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, StateType]:
"""Return the device state attributes."""
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
"longitude",
}
class TrackerEntity(
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
__in_zones: list[str] | None = None
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
return False
@property
def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
return self._attr_location_accuracy
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self._attr_latitude
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self._attr_longitude
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
super()._async_write_ha_state()
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.location_name is not None:
return self.location_name
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
state = STATE_HOME
else:
state = zone_state.name
return state
return None
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
"ip_address",
"mac_address",
"hostname",
}
class ScannerEntity(
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
entity_description: ScannerEntityDescription
_attr_hostname: str | None = None
_attr_ip_address: str | None = None
_attr_mac_address: str | None = None
_attr_source_type: SourceType = SourceType.ROUTER
@cached_property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._attr_ip_address
@cached_property
def mac_address(self) -> str | None:
"""Return the mac address of the device."""
return self._attr_mac_address
@cached_property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return self._attr_hostname
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return self.mac_address
@final
@property
def device_info(self) -> DeviceInfo | None:
"""Device tracker entities should not create device registry entries."""
return None
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
return (
# Do not disable if we won't activate our attach to device logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.mac_address and self.unique_id:
_async_register_mac(
hass,
platform.platform_name,
self.mac_address,
self.unique_id,
)
if self.is_connected and self.ip_address:
_async_connected_device_registered(
hass,
self.mac_address,
self.ip_address,
self.hostname,
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
async def async_internal_added_to_hass(self) -> None:
"""Handle added to Home Assistant."""
# Entities without a unique ID don't have a device
if (
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
):
if self.device_info:
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
await super().async_internal_added_to_hass()
return
# Attach entry to device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
# Attach device to config entry
if self.platform.config_entry.entry_id not in device_entry.config_entries:
dr.async_get(self.hass).async_update_device(
device_entry.id,
add_config_entry_id=self.platform.config_entry.entry_id,
)
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address
if (mac_address := self.mac_address) is not None:
attr[ATTR_MAC] = mac_address
if (hostname := self.hostname) is not None:
attr[ATTR_HOST_NAME] = hostname
return attr
@@ -1,494 +0,0 @@
"""Provide functionality to keep track of devices."""
import asyncio
from typing import Any, final
from propcache.api import cached_property
from homeassistant.components import zone
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
STATE_HOME,
STATE_NOT_HOME,
EntityCategory,
)
from homeassistant.core import Event, HomeAssistant, State, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceInfo,
EventDeviceRegistryUpdatedData,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_HOST_NAME,
ATTR_IN_ZONES,
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
LOGGER,
SourceType,
)
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
@callback
def _async_connected_device_registered(
hass: HomeAssistant, mac: str, ip_address: str | None, hostname: str | None
) -> None:
"""Register a newly seen connected device.
This is currently used by the dhcp integration
to listen for newly registered connected devices
for discovery.
"""
async_dispatcher_send(
hass,
CONNECTED_DEVICE_REGISTERED,
{
ATTR_IP: ip_address,
ATTR_MAC: mac,
ATTR_HOST_NAME: hostname,
},
)
@callback
def _async_register_mac(
hass: HomeAssistant,
domain: str,
mac: str,
unique_id: str,
) -> None:
"""Register a mac address with a unique ID."""
mac = dr.format_mac(mac)
if DATA_KEY in hass.data:
hass.data[DATA_KEY][mac] = (domain, unique_id)
return
# Setup listening.
# dict mapping mac -> partial unique ID
data = hass.data[DATA_KEY] = {mac: (domain, unique_id)}
@callback
def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None:
"""Enable the online status entity for the mac of a newly created device."""
# Only for new devices
if ev.data["action"] != "create":
return
dev_reg = dr.async_get(hass)
device_entry = dev_reg.async_get(ev.data["device_id"])
if device_entry is None:
# This should not happen, since the device was just created.
return
# Check if device has a mac
mac = None
for conn in device_entry.connections:
if conn[0] == dr.CONNECTION_NETWORK_MAC:
mac = conn[1]
break
if mac is None:
return
# Check if we have an entity for this mac
if (unique_id := data.get(mac)) is None:
return
ent_reg = er.async_get(hass)
if (entity_id := ent_reg.async_get_entity_id(DOMAIN, *unique_id)) is None:
return
entity_entry = ent_reg.entities[entity_id]
# Make sure entity has a config entry and was disabled by the
# default disable logic in the integration and new entities
# are allowed to be added.
if (
entity_entry.config_entry_id is None
or (
(
config_entry := hass.config_entries.async_get_entry(
entity_entry.config_entry_id
)
)
is not None
and config_entry.pref_disable_new_entities
)
or entity_entry.disabled_by != er.RegistryEntryDisabler.INTEGRATION
):
return
# Enable entity
ent_reg.async_update_entity(entity_id, disabled_by=None)
hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event)
class BaseTrackerEntity(Entity):
"""Represent a tracked device.
Not intended to be directly inherited by integrations. Integrations should
inherit TrackerEntity, BaseScannerEntity or ScannerEntity instead.
"""
_attr_device_info: None = None
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_source_type: SourceType
@cached_property
def battery_level(self) -> int | None:
"""Return the battery level of the device.
Percentage from 0-100.
"""
return None
@property
def source_type(self) -> SourceType:
"""Return the source type, eg gps or router, of the device."""
if hasattr(self, "_attr_source_type"):
return self._attr_source_type
raise NotImplementedError
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_SOURCE_TYPE: self.source_type}
if self.battery_level is not None:
attr[ATTR_BATTERY_LEVEL] = self.battery_level
return attr
class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = {
"in_zones",
"latitude",
"location_accuracy",
"location_name",
"longitude",
}
class TrackerEntity(
BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device."""
entity_description: TrackerEntityDescription
_attr_in_zones: list[str] | None = None
_attr_latitude: float | None = None
_attr_location_accuracy: float = 0
_attr_location_name: str | None = None
_attr_longitude: float | None = None
_attr_source_type: SourceType = SourceType.GPS
__active_zone: State | None = None
__in_zones: list[str] | None = None
@cached_property
def should_poll(self) -> bool:
"""No polling for entities that have location pushed."""
return False
@property
def force_update(self) -> bool:
"""All updates need to be written to the state machine if we're not polling."""
return not self.should_poll
@cached_property
def in_zones(self) -> list[str] | None:
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
"""
return self._attr_in_zones
@cached_property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device.
Value in meters.
"""
return self._attr_location_accuracy
@cached_property
def location_name(self) -> str | None:
"""Return a location name for the current location of the device."""
return self._attr_location_name
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self._attr_latitude
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self._attr_longitude
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
for entity_id in zones
if (zone_state := self.hass.states.get(entity_id)) is not None
),
key=lambda z: z.attributes[ATTR_RADIUS],
)
self.__active_zone = next(
(z for z in zone_states if not z.attributes.get(ATTR_PASSIVE)),
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
else:
self.__active_zone = None
self.__in_zones = None
super()._async_write_ha_state()
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.location_name is not None:
return self.location_name
if (
self.latitude is not None and self.longitude is not None
) or self.__in_zones is not None:
zone_state = self.__active_zone
if zone_state is None:
state = STATE_NOT_HOME
elif zone_state.entity_id == zone.ENTITY_ID_HOME:
state = STATE_HOME
else:
state = zone_state.name
return state
return None
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: self.__in_zones or []}
attr.update(super().state_attributes)
if self.latitude is not None and self.longitude is not None:
attr[ATTR_LATITUDE] = self.latitude
attr[ATTR_LONGITUDE] = self.longitude
attr[ATTR_GPS_ACCURACY] = self.location_accuracy
return attr
class BaseScannerEntity(BaseTrackerEntity):
"""Base class for a tracked device that can be connected or disconnected.
Unlike ScannerEntity, this entity does not make assumptions about MAC
addresses being used to identify the device.
"""
@property
def state(self) -> str | None:
"""Return the state of the device."""
if self.is_connected is None:
return None
if self.is_connected:
return STATE_HOME
return STATE_NOT_HOME
@property
def is_connected(self) -> bool | None:
"""Return true if the device is connected."""
raise NotImplementedError
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr: dict[str, Any] = {ATTR_IN_ZONES: []}
attr.update(super().state_attributes)
if not self.is_connected:
return attr
attr[ATTR_IN_ZONES] = [
zone.ENTITY_ID_HOME,
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
]
return attr
class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes tracker entities."""
CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = {
"ip_address",
"mac_address",
"hostname",
}
class ScannerEntity(
BaseScannerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_
):
"""Base class for a tracked device that is on a scanned network."""
entity_description: ScannerEntityDescription
_attr_hostname: str | None = None
_attr_ip_address: str | None = None
_attr_mac_address: str | None = None
_attr_source_type: SourceType = SourceType.ROUTER
@cached_property
def ip_address(self) -> str | None:
"""Return the primary ip address of the device."""
return self._attr_ip_address
@cached_property
def mac_address(self) -> str | None:
"""Return the mac address of the device."""
return self._attr_mac_address
@cached_property
def hostname(self) -> str | None:
"""Return hostname of the device."""
return self._attr_hostname
@property
def unique_id(self) -> str | None:
"""Return unique ID of the entity."""
return self.mac_address
@final
@property
def device_info(self) -> DeviceInfo | None:
"""Device tracker entities should not create device registry entries."""
return None
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if entity is enabled by default."""
# If mac_address is None, we can never find a device entry.
return (
# Do not disable if we won't activate our attach to device logic
self.mac_address is None
or self.device_info is not None
# Disable if we automatically attach but there is no device
or self.find_device_entry() is not None
)
@callback
def add_to_platform_start(
self,
hass: HomeAssistant,
platform: EntityPlatform,
parallel_updates: asyncio.Semaphore | None,
) -> None:
"""Start adding an entity to a platform."""
super().add_to_platform_start(hass, platform, parallel_updates)
if self.mac_address and self.unique_id:
_async_register_mac(
hass,
platform.platform_name,
self.mac_address,
self.unique_id,
)
if self.is_connected and self.ip_address:
_async_connected_device_registered(
hass,
self.mac_address,
self.ip_address,
self.hostname,
)
@callback
def find_device_entry(self) -> dr.DeviceEntry | None:
"""Return device entry."""
assert self.mac_address is not None
return dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)}
)
async def async_internal_added_to_hass(self) -> None:
"""Handle added to Home Assistant."""
# Entities without a unique ID don't have a device
if (
not self.registry_entry
or not self.platform.config_entry
or not self.mac_address
or (device_entry := self.find_device_entry()) is None
# Entities should not have a device info. We opt them out
# of this logic if they do.
or self.device_info
):
if self.device_info:
LOGGER.debug("Entity %s unexpectedly has a device info", self.entity_id)
await super().async_internal_added_to_hass()
return
# Attach entry to device
if self.registry_entry.device_id != device_entry.id:
self.registry_entry = er.async_get(self.hass).async_update_entity(
self.entity_id, device_id=device_entry.id
)
# Attach device to config entry
if self.platform.config_entry.entry_id not in device_entry.config_entries:
dr.async_get(self.hass).async_update_device(
device_entry.id,
add_config_entry_id=self.platform.config_entry.entry_id,
)
# Do this last or else the entity registry update listener has been installed
await super().async_internal_added_to_hass()
# BaseScannerEntity.state_attributes is @final to keep external subclasses
# from tampering with it; ScannerEntity is an in-tree subclass that
# intentionally extends it with ip/mac/hostname.
@final # type: ignore[misc]
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""
attr = super().state_attributes
if ip_address := self.ip_address:
attr[ATTR_IP] = ip_address
if (mac_address := self.mac_address) is not None:
attr[ATTR_MAC] = mac_address
if (hostname := self.hostname) is not None:
attr[ATTR_HOST_NAME] = hostname
return attr
@@ -1,6 +1,6 @@
"""Device tracker platform for fressnapf_tracker."""
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -13,9 +13,6 @@
"free_members": {
"default": "mdi:account-outline"
},
"gift_members": {
"default": "mdi:gift-outline"
},
"latest_email": {
"default": "mdi:email-newsletter"
},
-6
View File
@@ -70,12 +70,6 @@ SENSORS: tuple[GhostSensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.members.get("comped", 0),
),
GhostSensorEntityDescription(
key="gift_members",
translation_key="gift_members",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.members.get("gift", 0),
),
# Post metrics
GhostSensorEntityDescription(
key="published_posts",
@@ -62,9 +62,6 @@
"free_members": {
"name": "Free members"
},
"gift_members": {
"name": "Gift members"
},
"latest_email": {
"name": "Latest email"
},
@@ -6,7 +6,6 @@ import logging
from homeassistant.const import Platform
DOMAIN = "homewizard"
ISSUE_BATTERY_MODE_CLOUD_DISABLED = "battery_mode_cloud_disabled"
PLATFORMS = [
Platform.BUTTON,
Platform.NUMBER,
@@ -23,8 +22,3 @@ CONF_PRODUCT_TYPE = "product_type"
CONF_SERIAL = "serial"
UPDATE_INTERVAL = timedelta(seconds=5)
def battery_mode_cloud_issue_id(entry_id: str) -> str:
"""Build issue id for battery mode/cloud incompatibility."""
return f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_{entry_id}"
@@ -2,21 +2,14 @@
from homewizard_energy import HomeWizardEnergy
from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry
from homewizard_energy.models import CombinedModels as DeviceResponseEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
DOMAIN,
ISSUE_BATTERY_MODE_CLOUD_DISABLED,
LOGGER,
UPDATE_INTERVAL,
battery_mode_cloud_issue_id,
)
from .const import DOMAIN, LOGGER, UPDATE_INTERVAL
type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator]
@@ -45,34 +38,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
)
self.api = api
def _update_battery_mode_cloud_repair_issue(
self, data: DeviceResponseEntry
) -> None:
"""Update repair issue for incompatible battery mode and cloud state."""
battery_mode_cloud_issue_active = (
data.batteries is not None
and data.system is not None
and data.batteries.mode == Batteries.Mode.PREDICTIVE.value
and data.system.cloud_enabled is False
)
issue_id = battery_mode_cloud_issue_id(self.config_entry.entry_id)
issue_exists = (
ir.async_get(self.hass).async_get_issue(DOMAIN, issue_id) is not None
)
if battery_mode_cloud_issue_active and not issue_exists:
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=True,
is_persistent=False,
translation_key=ISSUE_BATTERY_MODE_CLOUD_DISABLED,
severity=ir.IssueSeverity.WARNING,
data={"entry_id": self.config_entry.entry_id},
)
elif not battery_mode_cloud_issue_active and issue_exists:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
async def _async_update_data(self) -> DeviceResponseEntry:
"""Fetch all device and sensor data from api."""
try:
@@ -105,7 +70,6 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry]
raise ConfigEntryAuthFailed from ex
self.api_disabled = False
self._update_battery_mode_cloud_repair_issue(data)
self.data = data
return data
+4 -47
View File
@@ -1,18 +1,11 @@
"""Repairs for HomeWizard integration."""
from homewizard_energy.errors import RequestError
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
from homeassistant.core import HomeAssistant
from .config_flow import async_request_token
from .const import ISSUE_BATTERY_MODE_CLOUD_DISABLED
class MigrateToV2ApiRepairFlow(RepairsFlow):
@@ -66,54 +59,18 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
return self.async_create_entry(data={})
class BatteryModeCloudDisabledRepairFlow(RepairsFlow):
"""Handler for a battery mode/cloud incompatibility fix flow."""
def __init__(self, entry: ConfigEntry) -> None:
"""Create flow."""
self.entry = entry
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
errors: dict[str, str] | None = None
if user_input is not None:
coordinator = self.entry.runtime_data
try:
await coordinator.api.system(cloud_enabled=True)
except RequestError:
errors = {"base": "network_error"}
else:
await coordinator.async_refresh()
return self.async_create_entry(data={})
return self.async_show_form(step_id="confirm", errors=errors)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if data is None or not isinstance(entry_id := data.get("entry_id"), str):
return ConfirmRepairFlow()
assert data is not None
assert isinstance(data["entry_id"], str)
if issue_id.startswith("migrate_to_v2_api_") and (
entry := hass.config_entries.async_get_entry(entry_id)
entry := hass.config_entries.async_get_entry(data["entry_id"])
):
return MigrateToV2ApiRepairFlow(entry)
if issue_id.startswith(f"{ISSUE_BATTERY_MODE_CLOUD_DISABLED}_") and (
entry := hass.config_entries.async_get_entry(entry_id)
):
return BatteryModeCloudDisabledRepairFlow(entry)
raise ValueError(f"unknown repair {issue_id}") # pragma: no cover
@@ -632,32 +632,6 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
has_fn=lambda data: data.measurement.cycles is not None,
value_fn=lambda data: data.measurement.cycles,
),
HomeWizardSensorEntityDescription(
key="battery_group_power_w",
translation_key="battery_group_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=False,
has_fn=lambda data: data.batteries is not None,
value_fn=lambda data: (
data.batteries.power_w if data.batteries is not None else None
),
),
HomeWizardSensorEntityDescription(
key="battery_group_target_power_w",
translation_key="battery_group_target_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=False,
has_fn=lambda data: data.batteries is not None,
value_fn=lambda data: (
data.batteries.target_power_w if data.batteries is not None else None
),
),
HomeWizardSensorEntityDescription(
key="uptime",
translation_key="uptime",
@@ -106,12 +106,6 @@
"any_power_fail_count": {
"name": "Power failures detected"
},
"battery_group_power_w": {
"name": "Battery group power"
},
"battery_group_target_power_w": {
"name": "Battery group target power"
},
"cycles": {
"name": "Battery cycles"
},
@@ -188,20 +182,6 @@
}
},
"issues": {
"battery_mode_cloud_disabled": {
"fix_flow": {
"error": {
"network_error": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"confirm": {
"description": "Smart charging strategy is enabled for your battery group, but cloud connection is disabled. These settings are not compatible, as smart charging requires cloud connectivity.\n\nSelect **Submit** to enable cloud connection.",
"title": "[%key:component::homewizard::issues::battery_mode_cloud_disabled::title%]"
}
}
},
"title": "Enable cloud connection for smart charging strategy"
},
"migrate_to_v2_api": {
"fix_flow": {
"error": {
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["python_qube_heatpump"],
"quality_scale": "bronze",
"requirements": ["python-qube-heatpump==1.11.0"]
"requirements": ["python-qube-heatpump==1.10.0"]
}
@@ -63,5 +63,5 @@
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["inkbird-ble==1.4.4"]
"requirements": ["inkbird-ble==1.4.3"]
}
@@ -15,11 +15,11 @@ from kiosker import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
from .const import CONF_API_TOKEN, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
_LOGGER = logging.getLogger(__name__)
@@ -2,6 +2,10 @@
DOMAIN = "kiosker"
# Configuration keys
# pylint: disable-next=home-assistant-duplicate-const
CONF_API_TOKEN = "api_token"
# Default values
PORT = 8081
POLL_INTERVAL = 15
@@ -18,12 +18,12 @@ from kiosker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, POLL_INTERVAL, PORT
from .const import CONF_API_TOKEN, DOMAIN, POLL_INTERVAL, PORT
_LOGGER = logging.getLogger(__name__)
@@ -4,7 +4,7 @@ import asyncio
import logging
from typing import Any
import serialx
import serial
import ultraheat_api
import voluptuous as vol
@@ -103,7 +103,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
# validate and retrieve the model and device number for a unique id
data = await self.hass.async_add_executor_job(heat_meter.read)
except (OSError, TimeoutError, serialx.SerialException) as err:
except (TimeoutError, serial.SerialException) as err:
_LOGGER.warning("Failed read data from: %s. %s", port, err)
raise CannotConnect(f"Error communicating with device: {err}") from err
@@ -3,7 +3,7 @@
import asyncio
import logging
import serialx
import serial
from ultraheat_api.response import HeatMeterResponse
from ultraheat_api.service import HeatMeterService
@@ -44,5 +44,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]):
try:
async with asyncio.timeout(ULTRAHEAT_TIMEOUT):
return await self.hass.async_add_executor_job(self.api.read)
except (OSError, TimeoutError, serialx.SerialException) as err:
except (FileNotFoundError, serial.SerialException) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["ultraheat-api==0.6.0"]
"requirements": ["ultraheat-api==0.5.7"]
}
@@ -125,7 +125,8 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity):
)
async def async_will_remove_from_hass(self) -> None:
"""Clean up expire triggers."""
"""Remove exprire triggers."""
# Clean up expire triggers
if self._expiration_trigger:
_LOGGER.debug("Clean up expire after trigger for %s", self.entity_id)
self._expiration_trigger()
@@ -354,7 +354,6 @@ from .const import (
CONF_TILT_STATE_OPTIMISTIC,
CONF_TILT_STATUS_TEMPLATE,
CONF_TILT_STATUS_TOPIC,
CONF_TIMEZONE,
CONF_TLS_INSECURE,
CONF_TRANSITION,
CONF_TRANSPORT,
@@ -462,8 +461,6 @@ SUBENTRY_PLATFORMS = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.COVER,
Platform.DATE,
Platform.DATETIME,
Platform.FAN,
Platform.IMAGE,
Platform.LIGHT,
@@ -475,7 +472,6 @@ SUBENTRY_PLATFORMS = [
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.TIME,
Platform.VALVE,
Platform.WATER_HEATER,
]
@@ -489,10 +485,6 @@ PWD_NOT_CHANGED = "__**password_not_changed**__"
DEVELOPER_DOCUMENTATION_URL = "https://developers.home-assistant.io/"
USER_DOCUMENTATION_URL = "https://www.home-assistant.io/"
TZ_ZONE_ABBR_URL = (
"https://en.wikipedia.org/wiki/List_of_tz_database_time_zones"
"#Time_zone_abbreviations"
)
INTEGRATION_URL = f"{USER_DOCUMENTATION_URL}integrations/{DOMAIN}/"
TEMPLATING_URL = f"{USER_DOCUMENTATION_URL}docs/configuration/templating/"
@@ -512,7 +504,6 @@ TRANSLATION_DESCRIPTION_PLACEHOLDERS = {
"available_state_classes_url": AVAILABLE_STATE_CLASSES_URL,
"naming_entities_url": NAMING_ENTITIES_URL,
"registry_properties_url": REGISTRY_PROPERTIES_URL,
"tz_abbr_url": TZ_ZONE_ABBR_URL,
}
# Common selectors
@@ -1246,8 +1237,6 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.BUTTON: None,
Platform.CLIMATE: validate_climate_platform_config,
Platform.COVER: validate_cover_platform_config,
Platform.DATE: None,
Platform.DATETIME: None,
Platform.FAN: validate_fan_platform_config,
Platform.IMAGE: None,
Platform.LIGHT: validate_light_platform_config,
@@ -1259,7 +1248,6 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.SIREN: None,
Platform.SWITCH: None,
Platform.TEXT: validate_text_platform_config,
Platform.TIME: None,
Platform.VALVE: None,
Platform.WATER_HEATER: validate_water_heater_platform_config,
}
@@ -1425,8 +1413,6 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
required=False,
),
},
Platform.DATE: {},
Platform.DATETIME: {},
Platform.FAN: {
"fan_feature_speed": PlatformField(
selector=BOOLEAN_SELECTOR,
@@ -1531,7 +1517,6 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
),
},
Platform.TEXT: {},
Platform.TIME: {},
Platform.VALVE: {
CONF_DEVICE_CLASS: PlatformField(
selector=VALVE_DEVICE_CLASS_SELECTOR, required=False, default=None
@@ -2381,61 +2366,6 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
section="cover_tilt_settings",
),
},
Platform.DATE: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.DATETIME: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_TIMEZONE: PlatformField(selector=TEXT_SELECTOR, required=False),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.FAN: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
@@ -3543,33 +3473,6 @@ PLATFORM_MQTT_FIELDS: dict[Platform, dict[str, PlatformField]] = {
section="text_advanced_settings",
),
},
Platform.TIME: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=True,
validator=valid_publish_topic,
error="invalid_publish_topic",
),
CONF_COMMAND_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_STATE_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
required=False,
validator=valid_subscribe_topic,
error="invalid_subscribe_topic",
),
CONF_VALUE_TEMPLATE: PlatformField(
selector=TEMPLATE_SELECTOR,
required=False,
validator=validate(cv.template),
error="invalid_template",
),
CONF_RETAIN: PlatformField(selector=BOOLEAN_SELECTOR, required=False),
},
Platform.VALVE: {
CONF_COMMAND_TOPIC: PlatformField(
selector=TEXT_SELECTOR,
-1
View File
@@ -56,7 +56,6 @@ CONF_RETAIN = ATTR_RETAIN
CONF_SCHEMA = "schema"
CONF_STATE_TOPIC = "state_topic"
CONF_STATE_VALUE_TEMPLATE = "state_value_template"
CONF_TIMEZONE = "timezone"
CONF_TOPIC = "topic"
CONF_TRANSPORT = "transport"
CONF_WS_PATH = "ws_path"
+2 -1
View File
@@ -27,7 +27,6 @@ from .const import (
CONF_COMMAND_TEMPLATE,
CONF_COMMAND_TOPIC,
CONF_STATE_TOPIC,
CONF_TIMEZONE,
PAYLOAD_NONE,
)
from .entity import MqttEntity, async_setup_entity_entry_helper
@@ -41,6 +40,8 @@ from .schemas import MQTT_ENTITY_COMMON_SCHEMA
_LOGGER = logging.getLogger(__name__)
CONF_TIMEZONE = "timezone"
PARALLEL_UPDATES = 0
DEFAULT_NAME = "MQTT Date/Time"
+1 -1
View File
@@ -1240,7 +1240,7 @@ class MqttDiscoveryUpdateMixin(Entity):
super().add_to_platform_abort()
async def async_will_remove_from_hass(self) -> None:
"""Stop listening to signal and cleanup discovery data."""
"""Stop listening to signal and cleanup discovery data.."""
self._cleanup_discovery_on_remove()
def _cleanup_discovery_on_remove(self) -> None:
@@ -378,7 +378,6 @@
"support_duration": "Duration support",
"support_volume_set": "Set volume support",
"supported_color_modes": "Supported color modes",
"timezone": "Time zone",
"url_template": "URL template",
"url_topic": "URL topic",
"value_template": "Value template"
@@ -431,7 +430,6 @@
"support_duration": "The siren supports setting a duration in second. The `duration` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_duration)",
"support_volume_set": "The siren supports setting a volume. The `volume_level` variable will become available for use in the \"Command template\" setting. [Learn more.]({url}#support_volume_set)",
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
"timezone": "Set to a valid [IANA time zone identifier]({tz_abbr_url}). Do not set this option if the date/time structure is providing time zone information via the status update.",
"url_template": "[Template]({value_templating_url}) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
"value_template": "Defines a [template]({value_templating_url}) to extract the {platform} entity value. [Learn more.]({url}#value_template)"
@@ -1470,8 +1468,6 @@
"button": "[%key:component::button::title%]",
"climate": "[%key:component::climate::title%]",
"cover": "[%key:component::cover::title%]",
"date": "[%key:component::date::title%]",
"datetime": "[%key:component::datetime::title%]",
"fan": "[%key:component::fan::title%]",
"image": "[%key:component::image::title%]",
"light": "[%key:component::light::title%]",
@@ -1483,7 +1479,6 @@
"siren": "[%key:component::siren::title%]",
"switch": "[%key:component::switch::title%]",
"text": "[%key:component::text::title%]",
"time": "[%key:component::time::title%]",
"valve": "[%key:component::valve::title%]",
"water_heater": "[%key:component::water_heater::title%]"
}
+6 -5
View File
@@ -98,11 +98,12 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
return
if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data:
if schedule := self.hass.data[DOMAIN][DATA_SCHEDULES][
self.home.entity_id
].get(data["schedule_id"]):
self._attr_current_option = schedule.name
self.async_write_ha_state()
self._attr_current_option = (
self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get(
data["schedule_id"]
)
).name
self.async_write_ha_state()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
@@ -2,7 +2,7 @@
from typing import Any, Final
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +27,6 @@ DEVICE_SUPPORT = {
"3B": (),
"42": (),
"7E": ("EDS0065", "EDS0066", "EDS0068"),
"81": (),
"A6": (),
"EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"),
}
@@ -88,13 +88,9 @@ def _format_tool(
custom_serializer: Callable[[Any], Any] | None,
) -> ChatCompletionFunctionToolParam:
"""Format tool specification."""
unsupported_keys = {"oneOf", "anyOf", "allOf"}
schema = convert(tool.parameters, custom_serializer=custom_serializer)
schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
tool_spec = FunctionDefinition(
name=tool.name,
parameters=schema,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
)
if tool.description:
tool_spec["description"] = tool.description
@@ -6,11 +6,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
_PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.VALVE,
]
@@ -1,203 +0,0 @@
"""Climate platform for the Ouman EH-800 integration."""
from dataclasses import dataclass
from typing import Any
from ouman_eh_800_api import (
EnumControlOumanEndpoint,
IntControlOumanEndpoint,
L1BaseEndpoints,
L1RoomSensor,
L2BaseEndpoints,
L2RoomSensor,
NumberOumanEndpoint,
OperationMode,
)
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import OumanDevice
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
from .entity import OumanEh800Entity, OumanEh800EntityDescription
PARALLEL_UPDATES = 1
# Operation modes that map to HVACMode.HEAT and use the climate's room
# temperature setpoint. The remaining modes (NORMAL_TEMPERATURE,
# MANUAL_VALVE_CONTROL, SHUTDOWN) ignore the setpoint and are reported as
# HVACMode.OFF.
_HEAT_OPERATION_MODES: tuple[OperationMode, ...] = (
OperationMode.AUTOMATIC,
OperationMode.TEMPERATURE_DROP,
OperationMode.BIG_TEMPERATURE_DROP,
)
_PRESET_TO_OPERATION_MODE: dict[str, OperationMode] = {
mode.name.lower(): mode for mode in _HEAT_OPERATION_MODES
}
# Operation mode written when the user switches to HVACMode.HEAT or
# turns the entity on without picking a specific preset first.
_DEFAULT_HEAT_OPERATION_MODE = OperationMode.AUTOMATIC
@dataclass(frozen=True, kw_only=True)
class OumanEh800ClimateEntityDescription(
OumanEh800EntityDescription, ClimateEntityDescription
):
"""Climate description identifying the endpoints that back one heating circuit."""
operation_mode_endpoint: EnumControlOumanEndpoint
current_temperature_endpoint: NumberOumanEndpoint
target_temperature_endpoint: IntControlOumanEndpoint
valve_position_endpoint: NumberOumanEndpoint
CLIMATE_DESCRIPTIONS: tuple[OumanEh800ClimateEntityDescription, ...] = (
OumanEh800ClimateEntityDescription(
device=OumanDevice.L1,
key="climate",
translation_key="heating_circuit",
operation_mode_endpoint=L1BaseEndpoints.OPERATION_MODE,
current_temperature_endpoint=L1RoomSensor.ROOM_TEMPERATURE,
target_temperature_endpoint=L1RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER,
valve_position_endpoint=L1BaseEndpoints.VALVE_POSITION,
),
OumanEh800ClimateEntityDescription(
device=OumanDevice.L2,
key="climate",
translation_key="heating_circuit",
operation_mode_endpoint=L2BaseEndpoints.OPERATION_MODE,
current_temperature_endpoint=L2RoomSensor.ROOM_TEMPERATURE,
target_temperature_endpoint=L2RoomSensor.ROOM_TEMPERATURE_SETPOINT_USER,
valve_position_endpoint=L2BaseEndpoints.VALVE_POSITION,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OumanEh800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ouman EH-800 climate entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OumanEh800ClimateEntity(coordinator, description)
for description in CLIMATE_DESCRIPTIONS
if description.target_temperature_endpoint in coordinator.data
)
class OumanEh800ClimateEntity(OumanEh800Entity, ClimateEntity):
"""Ouman EH-800 per-circuit room-temperature climate entity."""
entity_description: OumanEh800ClimateEntityDescription
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF]
_attr_preset_modes = list(_PRESET_TO_OPERATION_MODE)
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: OumanEh800Coordinator,
description: OumanEh800ClimateEntityDescription,
) -> None:
"""Initialize the climate entity."""
super().__init__(
coordinator, description.target_temperature_endpoint, description
)
target_endpoint = description.target_temperature_endpoint
self._attr_min_temp = float(target_endpoint.min_val)
self._attr_max_temp = float(target_endpoint.max_val)
@property
def _operation_mode(self) -> OperationMode:
value = self.coordinator.data[self.entity_description.operation_mode_endpoint]
assert isinstance(value, OperationMode)
return value
@property
def hvac_mode(self) -> HVACMode:
"""Return HEAT only when the climate setpoint is controlling the circuit."""
if self._operation_mode in _HEAT_OPERATION_MODES:
return HVACMode.HEAT
return HVACMode.OFF
@property
def hvac_action(self) -> HVACAction:
"""Return HEATING when the mixing valve is open, IDLE when closed, OFF otherwise."""
if self.hvac_mode is HVACMode.OFF:
return HVACAction.OFF
valve_position = self.coordinator.data[
self.entity_description.valve_position_endpoint
]
assert isinstance(valve_position, float)
return HVACAction.HEATING if valve_position > 0 else HVACAction.IDLE
@property
def preset_mode(self) -> str | None:
"""Return the current heating sub-mode, or None when shut down."""
mode = self._operation_mode
return mode.name.lower() if mode in _HEAT_OPERATION_MODES else None
@property
def current_temperature(self) -> float:
"""Return the current room temperature."""
value = self.coordinator.data[
self.entity_description.current_temperature_endpoint
]
assert isinstance(value, float)
return value
@property
def target_temperature(self) -> float:
"""Return the user-set room temperature setpoint."""
value = self.coordinator.data[
self.entity_description.target_temperature_endpoint
]
assert isinstance(value, float)
return value
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set a new room temperature setpoint and optionally the HVAC mode."""
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_set_hvac_mode(hvac_mode)
await self.coordinator.async_set_endpoint_value(
self.entity_description.target_temperature_endpoint,
int(kwargs[ATTR_TEMPERATURE]),
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Switch between heating (default sub-mode) and shutdown."""
new_mode = (
OperationMode.SHUTDOWN
if hvac_mode is HVACMode.OFF
else _DEFAULT_HEAT_OPERATION_MODE
)
await self.coordinator.async_set_endpoint_value(
self.entity_description.operation_mode_endpoint, new_mode
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Switch the heating sub-mode."""
await self.coordinator.async_set_endpoint_value(
self.entity_description.operation_mode_endpoint,
_PRESET_TO_OPERATION_MODE[preset_mode],
)
@@ -4,7 +4,6 @@ from datetime import timedelta
import logging
from ouman_eh_800_api import (
ControllableEndpoint,
L1BaseEndpoints,
L2BaseEndpoints,
OumanClientAuthenticationError,
@@ -18,11 +17,7 @@ from ouman_eh_800_api import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -102,21 +97,6 @@ class OumanEh800Coordinator(DataUpdateCoordinator[dict[OumanEndpoint, OumanValue
except OumanClientCommunicationError as err:
raise UpdateFailed("Error communicating with API") from err
async def async_set_endpoint_value(
self, endpoint: ControllableEndpoint, value: OumanValues | int
) -> None:
"""Set a value on the device and refresh."""
try:
result = await self.client.set_endpoint_value(endpoint, value)
except OumanClientAuthenticationError as err:
raise HomeAssistantError("Authentication failed") from err
except OumanClientCommunicationError as err:
raise HomeAssistantError("Error communicating with API") from err
self.async_set_updated_data({**self.data, endpoint: result})
# Separate refresh on all endpoints to catch cascading changes.
await self.async_request_refresh()
def sync_circuit_device_names(self) -> None:
"""Set the device-reported circuit names for the L1/L2 sub-device names.
@@ -1,260 +0,0 @@
"""Number platform for the Ouman EH-800 integration."""
from dataclasses import dataclass
from ouman_eh_800_api import (
FloatControlOumanEndpoint,
IntControlOumanEndpoint,
L1BaseEndpoints,
L1ConstantTempMode,
L1FivePointCurve,
L1NoRoomSensor,
L1RoomSensor,
L1ThreePointCurve,
L2BaseEndpoints,
L2FivePointCurve,
L2NoRoomSensor,
L2RoomSensor,
L2ThreePointCurve,
SystemEndpoints,
)
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import OumanDevice
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
from .entity import OumanEh800Entity, OumanEh800EntityDescription
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class OumanEh800NumberEntityDescription(
OumanEh800EntityDescription, NumberEntityDescription
):
"""Number description with main/L1/L2 device assignment."""
def _temperature_number(
*,
device: OumanDevice,
key: str,
device_class: NumberDeviceClass = NumberDeviceClass.TEMPERATURE,
entity_category: EntityCategory | None = EntityCategory.CONFIG,
enabled_by_default: bool = True,
) -> OumanEh800NumberEntityDescription:
return OumanEh800NumberEntityDescription(
device=device,
key=key,
translation_key=key,
device_class=device_class,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
mode=NumberMode.BOX,
entity_category=entity_category,
entity_registry_enabled_default=enabled_by_default,
)
NUMBER_DESCRIPTIONS: dict[
IntControlOumanEndpoint | FloatControlOumanEndpoint,
OumanEh800NumberEntityDescription,
] = {
SystemEndpoints.TREND_SAMPLE_INTERVAL: OumanEh800NumberEntityDescription(
device=OumanDevice.MAIN,
key="trend_sampling_interval",
translation_key="trend_sampling_interval",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
# L1 base water-out temperature limits.
L1BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number(
device=OumanDevice.L1, key="water_out_minimum_temperature"
),
L1BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number(
device=OumanDevice.L1, key="water_out_maximum_temperature"
),
# L1 heating curve. Three-point and five-point variants share keys
# where their meaning overlaps.
L1ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_20_temperature"
),
L1ThreePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_0_temperature"
),
L1ThreePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_20_temperature"
),
L1FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_20_temperature"
),
L1FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_minus_10_temperature"
),
L1FivePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_0_temperature"
),
L1FivePointCurve.CURVE_10_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_10_temperature"
),
L1FivePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L1, key="curve_20_temperature"
),
# L1 no-room-sensor and room-sensor variants share keys for the offsets
# that conceptually mean the same thing on both axes.
L1NoRoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L1,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L1,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L1,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L1ConstantTempMode.CONSTANT_TEMP_SETPOINT: _temperature_number(
device=OumanDevice.L1,
key="constant_temp_setpoint",
entity_category=None,
),
# L2 mirrors L1.
L2BaseEndpoints.WATER_OUT_MIN_TEMP: _temperature_number(
device=OumanDevice.L2, key="water_out_minimum_temperature"
),
L2BaseEndpoints.WATER_OUT_MAX_TEMP: _temperature_number(
device=OumanDevice.L2, key="water_out_maximum_temperature"
),
L2ThreePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_20_temperature"
),
L2ThreePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_0_temperature"
),
L2ThreePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_20_temperature"
),
L2FivePointCurve.CURVE_MINUS_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_20_temperature"
),
L2FivePointCurve.CURVE_MINUS_10_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_minus_10_temperature"
),
L2FivePointCurve.CURVE_0_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_0_temperature"
),
L2FivePointCurve.CURVE_10_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_10_temperature"
),
L2FivePointCurve.CURVE_20_TEMP: _temperature_number(
device=OumanDevice.L2, key="curve_20_temperature"
),
L2NoRoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2NoRoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2NoRoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L2,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.BIG_TEMPERATURE_DROP: _temperature_number(
device=OumanDevice.L2,
key="big_temperature_drop",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
L2RoomSensor.ROOM_TEMPERATURE_FINE_TUNING: _temperature_number(
device=OumanDevice.L2,
key="room_temperature_fine_tuning",
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: OumanEh800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ouman EH-800 number entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OumanEh800NumberEntity(coordinator, endpoint, description)
for endpoint in coordinator.data
if isinstance(endpoint, IntControlOumanEndpoint | FloatControlOumanEndpoint)
and (description := NUMBER_DESCRIPTIONS.get(endpoint)) is not None
)
class OumanEh800NumberEntity(OumanEh800Entity, NumberEntity):
"""Ouman EH-800 number entity."""
entity_description: OumanEh800NumberEntityDescription
_endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint
def __init__(
self,
coordinator: OumanEh800Coordinator,
endpoint: IntControlOumanEndpoint | FloatControlOumanEndpoint,
description: OumanEh800NumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator, endpoint, description)
self._attr_native_min_value = float(endpoint.min_val)
self._attr_native_max_value = float(endpoint.max_val)
self._attr_native_step = (
1 if isinstance(endpoint, IntControlOumanEndpoint) else 0.1
)
@property
def native_value(self) -> float:
"""Return the current value."""
value = self.coordinator.data[self._endpoint]
assert isinstance(value, float)
return value
async def async_set_native_value(self, value: float) -> None:
"""Set a new value on the device."""
final_value: int | float = (
int(value) if isinstance(self._endpoint, IntControlOumanEndpoint) else value
)
await self.coordinator.async_set_endpoint_value(self._endpoint, final_value)
@@ -1,120 +0,0 @@
"""Select platform for the Ouman EH-800 integration."""
from dataclasses import dataclass
from ouman_eh_800_api import (
ControlEnum,
EnumControlOumanEndpoint,
L1BaseEndpoints,
L2BaseEndpoints,
RelayL1ValvePosition,
RelayPumpSummerStop,
RelayTempDifference,
RelayTemperature,
RelayTimeProgram,
SystemEndpoints,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import OumanDevice
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
from .entity import OumanEh800Entity, OumanEh800EntityDescription
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class OumanEh800SelectEntityDescription(
OumanEh800EntityDescription, SelectEntityDescription
):
"""Select description with main/L1/L2 device assignment."""
def _select_entity(
*,
device: OumanDevice,
key: str,
) -> OumanEh800SelectEntityDescription:
return OumanEh800SelectEntityDescription(
device=device,
key=key,
translation_key=key,
)
SELECT_DESCRIPTIONS: dict[
EnumControlOumanEndpoint, OumanEh800SelectEntityDescription
] = {
SystemEndpoints.HOME_AWAY_MODE: _select_entity(
device=OumanDevice.MAIN, key="home_away_mode"
),
L1BaseEndpoints.OPERATION_MODE: _select_entity(
device=OumanDevice.L1, key="operation_mode"
),
L2BaseEndpoints.OPERATION_MODE: _select_entity(
device=OumanDevice.L2, key="operation_mode"
),
RelayPumpSummerStop.CONTROL: _select_entity(
device=OumanDevice.MAIN, key="relay_pump_summer_stop_control"
),
RelayTemperature.CONTROL: _select_entity(
device=OumanDevice.MAIN, key="relay_control"
),
RelayTempDifference.CONTROL: _select_entity(
device=OumanDevice.MAIN, key="relay_control"
),
RelayL1ValvePosition.CONTROL: _select_entity(
device=OumanDevice.MAIN, key="relay_control"
),
RelayTimeProgram.CONTROL: _select_entity(
device=OumanDevice.MAIN, key="relay_control"
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: OumanEh800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ouman EH-800 select entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OumanEh800SelectEntity(coordinator, endpoint, description)
for endpoint in coordinator.data
if isinstance(endpoint, EnumControlOumanEndpoint)
and (description := SELECT_DESCRIPTIONS.get(endpoint)) is not None
)
class OumanEh800SelectEntity(OumanEh800Entity, SelectEntity):
"""Ouman EH-800 select entity."""
entity_description: OumanEh800SelectEntityDescription
_endpoint: EnumControlOumanEndpoint
def __init__(
self,
coordinator: OumanEh800Coordinator,
endpoint: EnumControlOumanEndpoint,
description: OumanEh800SelectEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator, endpoint, description)
self._attr_options = [member.name.lower() for member in endpoint.enum_type]
@property
def current_option(self) -> str:
"""Return the currently selected option."""
value = self.coordinator.data[self._endpoint]
assert isinstance(value, ControlEnum)
return value.name.lower()
async def async_select_option(self, option: str) -> None:
"""Change the selected option on the device."""
await self.coordinator.async_set_endpoint_value(
self._endpoint, self._endpoint.enum_type[option.upper()]
)
@@ -31,76 +31,6 @@
}
},
"entity": {
"climate": {
"heating_circuit": {
"state_attributes": {
"preset_mode": {
"state": {
"automatic": "[%key:common::state::auto%]",
"big_temperature_drop": "[%key:component::ouman_eh_800::entity::number::big_temperature_drop::name%]",
"temperature_drop": "[%key:component::ouman_eh_800::entity::number::temperature_drop::name%]"
}
}
}
}
},
"number": {
"big_temperature_drop": { "name": "Big temperature drop" },
"constant_temp_setpoint": { "name": "Constant temperature setpoint" },
"curve_0_temperature": { "name": "Curve 0°C temperature" },
"curve_10_temperature": { "name": "Curve 10°C temperature" },
"curve_20_temperature": { "name": "Curve 20°C temperature" },
"curve_minus_10_temperature": { "name": "Curve -10°C temperature" },
"curve_minus_20_temperature": { "name": "Curve -20°C temperature" },
"room_temperature_fine_tuning": {
"name": "Room temperature fine tuning"
},
"temperature_drop": { "name": "Temperature drop" },
"trend_sampling_interval": { "name": "Trend sampling interval" },
"water_out_maximum_temperature": {
"name": "Water out maximum temperature"
},
"water_out_minimum_temperature": {
"name": "Water out minimum temperature"
}
},
"select": {
"home_away_mode": {
"name": "Home/Away mode",
"state": {
"away": "[%key:common::state::not_home%]",
"home": "[%key:common::state::home%]",
"off": "[%key:common::state::off%]"
}
},
"operation_mode": {
"name": "Operation mode",
"state": {
"automatic": "[%key:common::state::auto%]",
"big_temperature_drop": "[%key:component::ouman_eh_800::entity::number::big_temperature_drop::name%]",
"manual_valve_control": "Manual valve control",
"normal_temperature": "Nominal temperature",
"shutdown": "[%key:common::state::standby%]",
"temperature_drop": "[%key:component::ouman_eh_800::entity::number::temperature_drop::name%]"
}
},
"relay_control": {
"name": "Relay control",
"state": {
"auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"relay_pump_summer_stop_control": {
"name": "Pump summer stop",
"state": {
"auto": "[%key:common::state::auto%]",
"run": "Run",
"stop": "[%key:common::action::stop%]"
}
}
},
"sensor": {
"curve_supply_water_temperature": {
"name": "Curve supply water temperature"
@@ -119,9 +49,6 @@
"name": "Supply water temperature setpoint"
},
"valve_position": { "name": "Valve position" }
},
"valve": {
"valve_position_setpoint": { "name": "Valve position setpoint" }
}
}
}
@@ -1,83 +0,0 @@
"""Valve platform for the Ouman EH-800 integration."""
from dataclasses import dataclass
from ouman_eh_800_api import IntControlOumanEndpoint, L1BaseEndpoints, L2BaseEndpoints
from homeassistant.components.valve import (
ValveDeviceClass,
ValveEntity,
ValveEntityDescription,
ValveEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import OumanDevice
from .coordinator import OumanEh800ConfigEntry
from .entity import OumanEh800Entity, OumanEh800EntityDescription
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class OumanEh800ValveEntityDescription(
OumanEh800EntityDescription, ValveEntityDescription
):
"""Valve description with main/L1/L2 device assignment."""
VALVE_DESCRIPTIONS: dict[IntControlOumanEndpoint, OumanEh800ValveEntityDescription] = {
L1BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription(
device=OumanDevice.L1,
key="valve_position_setpoint",
translation_key="valve_position_setpoint",
device_class=ValveDeviceClass.WATER,
),
L2BaseEndpoints.VALVE_POSITION_SETPOINT: OumanEh800ValveEntityDescription(
device=OumanDevice.L2,
key="valve_position_setpoint",
translation_key="valve_position_setpoint",
device_class=ValveDeviceClass.WATER,
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: OumanEh800ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ouman EH-800 valve entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OumanEh800ValveEntity(coordinator, endpoint, description)
for endpoint in coordinator.data
if isinstance(endpoint, IntControlOumanEndpoint)
and (description := VALVE_DESCRIPTIONS.get(endpoint)) is not None
)
class OumanEh800ValveEntity(OumanEh800Entity, ValveEntity):
"""Ouman EH-800 valve entity."""
entity_description: OumanEh800ValveEntityDescription
_endpoint: IntControlOumanEndpoint
_attr_reports_position = True
_attr_supported_features = (
ValveEntityFeature.SET_POSITION
| ValveEntityFeature.OPEN
| ValveEntityFeature.CLOSE
)
@property
def current_valve_position(self) -> int:
"""Return the current valve position 0-100."""
value = self.coordinator.data[self._endpoint]
assert isinstance(value, float)
return int(value)
async def async_set_valve_position(self, position: int) -> None:
"""Move the valve to the given position."""
await self.coordinator.async_set_endpoint_value(self._endpoint, position)
@@ -5,7 +5,7 @@ Reads position data from PajGpsCoordinator and exposes it as a TrackerEntity.
import logging
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,7 +27,6 @@ from homeassistant.const import (
UnitOfPressure,
UnitOfTemperature,
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -1279,35 +1278,6 @@ CAPABILITY_TO_SENSORS: dict[
)
]
},
Capability.MIRRORHAPPY40050_COPPER_WATER_METER: {
Attribute.ENERGY_USAGE_DAY: [
SmartThingsSensorEntityDescription(
key=Attribute.ENERGY_USAGE_DAY,
translation_key="water_usage_day",
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfVolume.GALLONS,
)
],
Attribute.ENERGY_USAGE_MONTH: [
SmartThingsSensorEntityDescription(
key=Attribute.ENERGY_USAGE_MONTH,
translation_key="water_usage_month",
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfVolume.GALLONS,
)
],
Attribute.POWER_CURRENT: [
SmartThingsSensorEntityDescription(
key=Attribute.POWER_CURRENT,
translation_key="water_usage_current",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
)
],
},
}
@@ -988,15 +988,6 @@
"water_filter_usage": {
"name": "Water filter usage"
},
"water_usage_current": {
"name": "Current water usage"
},
"water_usage_day": {
"name": "Water usage today"
},
"water_usage_month": {
"name": "Water usage this month"
},
"x_coordinate": {
"name": "X coordinate"
},
+15 -16
View File
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_HAS_PWD, DEFAULT_TIMEOUT
from .const import CONF_HAS_PWD
from .coordinator import (
SolarLogBasicDataCoordinator,
SolarlogConfigEntry,
@@ -56,22 +56,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) ->
entry.runtime_data = solarLogData
_LOGGER.debug(
"Basic coordinator setup successful, extended data available: %s",
solarLogData.api.extended_data,
)
if basic_coordinator.solarlog.extended_data:
timeout = entry.data.get(CONF_TIMEOUT, 0)
if timeout <= 150:
# Increase timeout for next try, skip setup of LongtimeDataCoordinator,
# if timeout was not the issue (assumed when timeout > 150)
timeout = timeout + 30
new = {**entry.data}
new[CONF_TIMEOUT] = timeout
hass.config_entries.async_update_entry(entry, data=new)
if solarLogData.api.extended_data:
timeout = entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
_LOGGER.debug("Setup of LongtimeDataCoordinator, saved timeout is %s", timeout)
entry.runtime_data.longtime_data_coordinator = SolarLogLongtimeDataCoordinator(
hass, entry, solarlog, timeout
)
await entry.runtime_data.longtime_data_coordinator.async_config_entry_first_refresh()
_LOGGER.debug("Setup of DeviceDataCoordinator")
longtime_coordinator = SolarLogLongtimeDataCoordinator(
hass, entry, solarlog, timeout
)
entry.runtime_data.longtime_data_coordinator = longtime_coordinator
await longtime_coordinator.async_config_entry_first_refresh()
device_coordinator = SolarLogDeviceDataCoordinator(hass, entry, solarlog)
entry.runtime_data.device_data_coordinator = device_coordinator
@@ -13,9 +13,9 @@ from solarlog_cli.solarlog_exceptions import (
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_TIMEOUT, DOMAIN
from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN
class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -137,7 +137,6 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
if not user_input[CONF_HAS_PWD] or user_input.get(CONF_PASSWORD, "") == "":
user_input[CONF_PASSWORD] = ""
user_input[CONF_HAS_PWD] = False
user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=user_input
)
@@ -146,7 +145,6 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry.data[CONF_HOST], user_input.get(CONF_PASSWORD, "")
):
# if password has been provided, only save if extended data is available
user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=user_input,
@@ -4,6 +4,5 @@ DOMAIN = "solarlog"
# Default config for solarlog.
DEFAULT_HOST = "http://solar-log"
DEFAULT_TIMEOUT = 30
CONF_HAS_PWD = "has_password"
@@ -13,7 +13,6 @@ from solarlog_cli.solarlog_exceptions import (
from solarlog_cli.solarlog_models import EnergyData, InverterData, SolarlogData
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TIMEOUT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@@ -238,37 +237,6 @@ class SolarLogLongtimeDataCoordinator(DataUpdateCoordinator[EnergyData]):
self.solarlog = api
self.connection_timeout = timeout
async def _async_setup(self) -> None:
"""Do initialization logic."""
_LOGGER.debug("Start SolarLogLongtimeDataCoordinator async_setup")
try:
await self.solarlog.update_energy_data(timeout=self.connection_timeout)
except SolarLogAuthenticationError as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from ex
except (SolarLogConnectionError, SolarLogUpdateError) as ex:
if (
isinstance(ex.__cause__, TimeoutError)
and self.connection_timeout <= 150
):
# Increase timeout for next try
self.connection_timeout = self.connection_timeout + 30
_LOGGER.debug(
"Connection failed, increased timeout to %s for next try",
self.connection_timeout,
)
new = {**self.config_entry.data}
new[CONF_TIMEOUT] = self.connection_timeout
self.hass.config_entries.async_update_entry(self.config_entry, data=new)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from ex
async def _async_update_data(self) -> EnergyData:
"""Update the energy data from the SolarLog device."""
_LOGGER.debug(
@@ -86,7 +86,9 @@ class TeslaFleetCableLockEntity(TeslaFleetVehicleEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Charge cable Lock cannot be manually locked."""
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ServiceValidationError(
"Insert cable to lock",
translation_domain=DOMAIN,
translation_key="no_cable",
)
@@ -153,7 +153,6 @@ class UniversalMediaPlayer(MediaPlayerEntity):
"""Representation of an universal media player."""
_attr_should_poll = False
_attr_media_image_remotely_accessible = True
def __init__(
self,
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_SECUREON_PASSWORD, DOMAIN, PLATFORMS
from .const import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -21,7 +21,6 @@ SERVICE_SEND_MAGIC_PACKET = "send_magic_packet"
WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema(
{
vol.Required(CONF_MAC): cv.string,
vol.Optional(CONF_SECUREON_PASSWORD): cv.string,
vol.Optional(CONF_BROADCAST_ADDRESS): cv.string,
vol.Optional(CONF_BROADCAST_PORT): cv.port,
}
@@ -35,8 +34,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def send_magic_packet(call: ServiceCall) -> None:
"""Send magic packet to wake up a device."""
mac_address: str = call.data[CONF_MAC]
secureon_password = call.data.get(CONF_SECUREON_PASSWORD)
mac_address = call.data.get(CONF_MAC)
broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS)
broadcast_port = call.data.get(CONF_BROADCAST_PORT)
@@ -47,18 +45,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
service_kwargs["port"] = broadcast_port
_LOGGER.debug(
"Send magic packet to mac %s (secureon: %s, broadcast: %s, port: %s)",
"Send magic packet to mac %s (broadcast: %s, port: %s)",
mac_address,
secureon_password is not None,
broadcast_address,
broadcast_port,
)
if secureon_password:
mac_address += f"/{secureon_password}"
await hass.async_add_executor_job(
partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs)
partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) # type: ignore[arg-type]
)
hass.services.async_register(
+2 -13
View File
@@ -13,8 +13,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_SECUREON_PASSWORD
_LOGGER = logging.getLogger(__name__)
@@ -27,7 +25,6 @@ async def async_setup_entry(
broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS)
broadcast_port: int | None = entry.options.get(CONF_BROADCAST_PORT)
mac_address: str = entry.options[CONF_MAC]
secureon_password: str | None = entry.options.get(CONF_SECUREON_PASSWORD)
name: str = entry.title
async_add_entities(
@@ -35,7 +32,6 @@ async def async_setup_entry(
WolButton(
name,
mac_address,
secureon_password,
broadcast_address,
broadcast_port,
)
@@ -52,13 +48,11 @@ class WolButton(ButtonEntity):
self,
name: str,
mac_address: str,
secureon_password: str | None,
broadcast_address: str | None,
broadcast_port: int | None,
) -> None:
"""Initialize the WOL button."""
self._mac_address = mac_address
self._secureon_password = secureon_password
self._broadcast_address = broadcast_address
self._broadcast_port = broadcast_port
self._attr_unique_id = dr.format_mac(mac_address)
@@ -76,17 +70,12 @@ class WolButton(ButtonEntity):
service_kwargs["port"] = self._broadcast_port
_LOGGER.debug(
"Send magic packet to mac %s (secureon: %s, broadcast: %s, port: %s)",
"Send magic packet to mac %s (broadcast: %s, port: %s)",
self._mac_address,
self._secureon_password is not None,
self._broadcast_address,
self._broadcast_port,
)
mac = self._mac_address
if self._secureon_password:
mac += f"/{self._secureon_password}"
await self.hass.async_add_executor_job(
partial(wakeonlan.send_magic_packet, mac, **service_kwargs)
partial(wakeonlan.send_magic_packet, self._mac_address, **service_kwargs)
)
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
TextSelector,
)
from .const import CONF_SECUREON_PASSWORD, DEFAULT_NAME, DOMAIN
from .const import DEFAULT_NAME, DOMAIN
async def validate(
@@ -48,7 +48,6 @@ async def validate_options(
DATA_SCHEMA = {vol.Required(CONF_MAC): TextSelector()}
OPTIONS_SCHEMA = {
vol.Optional(CONF_SECUREON_PASSWORD): TextSelector(),
vol.Optional(CONF_BROADCAST_ADDRESS): TextSelector(),
vol.Optional(CONF_BROADCAST_PORT): NumberSelector(
NumberSelectorConfig(min=0, max=65535, step=1, mode=NumberSelectorMode.BOX)
@@ -6,7 +6,6 @@ DOMAIN = "wake_on_lan"
PLATFORMS = [Platform.BUTTON]
CONF_OFF_ACTION = "turn_off"
CONF_SECUREON_PASSWORD = "secureon_password"
DEFAULT_NAME = "Wake on LAN"
DEFAULT_PING_TIMEOUT = 1
@@ -5,11 +5,6 @@ send_magic_packet:
example: "aa:bb:cc:dd:ee:ff"
selector:
text:
secureon_password:
example: "11:22:33:44:55:66"
selector:
text:
type: password
broadcast_address:
example: 192.168.255.255
selector:
@@ -8,14 +8,12 @@
"data": {
"broadcast_address": "Broadcast address",
"broadcast_port": "Broadcast port",
"mac": "MAC address",
"secureon_password": "SecureOn password"
"mac": "MAC address"
},
"data_description": {
"broadcast_address": "The IP address of the host to send the magic packet to. Defaults to `255.255.255.255` and is normally not changed.",
"broadcast_port": "The port to send the magic packet to. Defaults to `9` and is normally not changed.",
"mac": "MAC address of the device to wake up.",
"secureon_password": "The SecureOn password in 6 bytes hexadecimal format to append to the magic packet."
"mac": "MAC address of the device to wake up."
}
}
}
@@ -28,13 +26,11 @@
"init": {
"data": {
"broadcast_address": "[%key:component::wake_on_lan::config::step::user::data::broadcast_address%]",
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]",
"secureon_password": "[%key:component::wake_on_lan::config::step::user::data::secureon_password%]"
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data::broadcast_port%]"
},
"data_description": {
"broadcast_address": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_address%]",
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]",
"secureon_password": "[%key:component::wake_on_lan::config::step::user::data_description::secureon_password%]"
"broadcast_port": "[%key:component::wake_on_lan::config::step::user::data_description::broadcast_port%]"
}
}
}
@@ -54,10 +50,6 @@
"mac": {
"description": "[%key:component::wake_on_lan::config::step::user::data_description::mac%]",
"name": "[%key:component::wake_on_lan::config::step::user::data::mac%]"
},
"secureon_password": {
"description": "[%key:component::wake_on_lan::config::step::user::data_description::secureon_password%]",
"name": "[%key:component::wake_on_lan::config::step::user::data::secureon_password%]"
}
},
"name": "Send magic packet"
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/yardian",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyyardian==1.3.3"]
"requirements": ["pyyardian==1.1.1"]
}
-45
View File
@@ -1,45 +0,0 @@
"""The Yoto integration."""
import aiohttp
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Set up Yoto from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
raise ConfigEntryNotReady from err
coordinator = YotoDataUpdateCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
"""Unload a Yoto config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,40 +0,0 @@
"""Application credentials platform for the Yoto integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
LocalOAuth2ImplementationWithPkce,
)
from .const import YOTO_AUDIENCE, YOTO_SCOPES
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
async def async_get_auth_implementation(
hass: HomeAssistant,
auth_domain: str,
credential: ClientCredential,
) -> YotoOAuth2Implementation:
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
return YotoOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
AUTHORIZE_URL,
TOKEN_URL,
credential.client_secret,
)
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
@property
def extra_authorize_data(self) -> dict:
"""Append Yoto's audience and scopes to every authorize URL."""
return super().extra_authorize_data | {
"audience": YOTO_AUDIENCE,
"scope": " ".join(YOTO_SCOPES),
}
@@ -1,35 +0,0 @@
"""Config flow for the Yoto integration."""
import logging
from typing import Any
from yoto_api import YotoError, get_account_id
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import _LOGGER, DOMAIN
class YotoOAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Authorize Home Assistant with a Yoto account using OAuth2."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return the logger used for the OAuth2 flow."""
return _LOGGER
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Identify the Yoto account from the access token."""
try:
user_id = get_account_id(data["token"]["access_token"])
except YotoError:
return self.async_abort(reason="oauth_unauthorized")
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Yoto", data=data)
-26
View File
@@ -1,26 +0,0 @@
"""Constants for the Yoto integration."""
from datetime import timedelta
import logging
DOMAIN = "yoto"
_LOGGER = logging.getLogger(__package__)
YOTO_AUDIENCE = "https://api.yotoplay.com"
YOTO_SCOPES = [
"offline_access",
"family:view",
"family:devices:view",
"family:devices:control",
"family:devices:manage",
"family:library:view",
"user:content:view",
"user:icons:manage",
]
SCAN_INTERVAL = timedelta(minutes=5)
STATUS_PUSH_INTERVAL = timedelta(seconds=60)
MANUFACTURER = "Yoto"
@@ -1,139 +0,0 @@
"""Coordinator for the Yoto integration."""
from datetime import datetime
import aiohttp
from yoto_api import Token, YotoClient, YotoError, YotoPlayer
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL
type YotoConfigEntry = ConfigEntry[YotoDataUpdateCoordinator]
class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
"""Coordinator that drives the Yoto cloud polling cycle."""
config_entry: YotoConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: YotoConfigEntry,
session: OAuth2Session,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self._session = session
self.client = YotoClient(session=async_get_clientsession(hass))
self._sync_token()
def _sync_token(self) -> None:
"""Sync the OAuth2 access token to the Yoto client."""
token = self._session.token
self.client.token = Token(
access_token=token[CONF_ACCESS_TOKEN],
refresh_token=token.get("refresh_token", ""),
token_type=token.get("token_type", "Bearer"),
valid_until=dt_util.utc_from_timestamp(token["expires_at"]),
)
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.client.refresh()
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
await self._async_load_library()
try:
await self.client.connect_events(
list(self.client.players), self._mqtt_event
)
except YotoError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
# The MQTT data/status topic is not pushed spontaneously; the firmware
# only emits it in response to a command/status/request publish.
self.config_entry.async_on_unload(
async_track_time_interval(
self.hass, self._async_status_push_tick, STATUS_PUSH_INTERVAL
)
)
async def _async_update_data(self) -> dict[str, YotoPlayer]:
"""Fetch fresh data from the Yoto cloud."""
# _async_setup already populated the client; skip the duplicate first fetch.
if self.data is None:
return self.client.players
try:
await self._session.async_ensure_token_valid()
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
self._sync_token()
try:
await self.client.refresh()
except YotoError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
return self.client.players
async def _async_load_library(self) -> None:
"""Load the card library; failures only affect titles and artwork."""
try:
await self.client.update_library()
except YotoError as err:
_LOGGER.warning("Could not load Yoto card library: %s", err)
async def _async_status_push_tick(self, _now: datetime) -> None:
"""Ask each player to push a fresh status snapshot over MQTT."""
if not self.client.is_mqtt_connected:
return
# Fire-and-forget: the data/status response lands via the on_update
# callback later, which already triggers async_set_updated_data.
for device_id in list(self.client.players):
await self.client.request_status_push(device_id)
def _mqtt_event(self, _player: YotoPlayer) -> None:
"""Handle a real-time update pushed by the Yoto MQTT broker."""
self.async_set_updated_data(self.client.players)
async def async_shutdown(self) -> None:
"""Shut down the coordinator."""
await self.client.disconnect_events()
await super().async_shutdown()
-46
View File
@@ -1,46 +0,0 @@
"""Base entity for the Yoto integration."""
from yoto_api import YotoPlayer
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import YotoDataUpdateCoordinator
class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
"""Base class for Yoto entities tied to a single player."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._player_id = player.id
device = player.device
mac = player.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, player.id)},
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
manufacturer=MANUFACTURER,
model=player.model,
model_id=device.device_type,
hw_version=device.generation,
name=player.name,
sw_version=player.info.firmware_version,
)
@property
def player(self) -> YotoPlayer:
"""Return the live player record from the client."""
return self.coordinator.data[self._player_id]
@property
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._player_id in self.coordinator.data
@@ -1,14 +0,0 @@
{
"domain": "yoto",
"name": "Yoto",
"codeowners": ["@cdnninja", "@piitaya"],
"config_flow": true,
"dependencies": ["application_credentials"],
"dhcp": [{ "hostname": "yoto-*" }],
"documentation": "https://www.home-assistant.io/integrations/yoto",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["yoto_api"],
"quality_scale": "bronze",
"requirements": ["yoto-api==3.1.3"]
}
@@ -1,183 +0,0 @@
"""Media player platform for the Yoto integration."""
from collections.abc import Awaitable, Callable
from datetime import datetime
from typing import Any
from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
from .entity import YotoEntity
PARALLEL_UPDATES = 0
# Yoto players expose 16 hardware volume steps.
VOLUME_STEP = 1 / 16
PLAYBACK_STATE_MAP = {
PlaybackStatus.PLAYING: MediaPlayerState.PLAYING,
PlaybackStatus.PAUSED: MediaPlayerState.PAUSED,
PlaybackStatus.STOPPED: MediaPlayerState.IDLE,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: YotoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yoto media player platform."""
coordinator = entry.runtime_data
async_add_entities(
YotoMediaPlayer(coordinator, player)
for player in coordinator.client.players.values()
)
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
"""Representation of a Yoto Player."""
_attr_name = None
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
_attr_media_image_remotely_accessible = True
_attr_volume_step = VOLUME_STEP
_attr_supported_features = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
)
def __init__(
self,
coordinator: YotoDataUpdateCoordinator,
player: YotoPlayer,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator, player)
self._attr_unique_id = player.id
@property
def available(self) -> bool:
"""Return whether the player is reachable through the Yoto cloud."""
return super().available and bool(self.player.status.is_online)
@property
def state(self) -> MediaPlayerState:
"""Return the playback state."""
return PLAYBACK_STATE_MAP.get(
self.player.last_event.playback_status, MediaPlayerState.IDLE
)
@property
def volume_level(self) -> float | None:
"""Return the current volume level."""
return self.player.last_event.volume_percentage
@property
def media_duration(self) -> int | None:
"""Return the current track duration in seconds."""
return self.player.last_event.track_length
@property
def media_position(self) -> int | None:
"""Return the current playback position in seconds."""
return self.player.last_event.position
@property
def media_position_updated_at(self) -> datetime | None:
"""Return the time the media position was last refreshed."""
return self.player.last_event_received_at
@property
def media_title(self) -> str | None:
"""Return the title of the currently playing track."""
event = self.player.last_event
return event.track_title or event.chapter_title
@property
def media_album_name(self) -> str | None:
"""Return the title of the active card."""
card = self._current_card()
return card.title if card else None
@property
def media_artist(self) -> str | None:
"""Return the author of the active card."""
card = self._current_card()
return card.author if card else None
@property
def media_image_url(self) -> str | None:
"""Return the cover image URL of the active card."""
card = self._current_card()
return card.cover_image_large if card else None
def _current_card(self) -> Card | None:
"""Return the cached library card for the currently active media."""
card_id = self.player.last_event.card_id
if not card_id:
return None
return self.coordinator.client.library.get(card_id)
async def async_media_play(self) -> None:
"""Resume playback."""
await self._async_run(self.coordinator.client.resume, self._player_id)
async def async_media_pause(self) -> None:
"""Pause playback."""
await self._async_run(self.coordinator.client.pause, self._player_id)
async def async_media_stop(self) -> None:
"""Stop playback."""
await self._async_run(self.coordinator.client.stop, self._player_id)
async def async_set_volume_level(self, volume: float) -> None:
"""Set the playback volume (0.0 - 1.0)."""
await self._async_run(
self.coordinator.client.set_volume,
self._player_id,
round(volume * 100),
)
async def async_media_seek(self, position: float) -> None:
"""Seek to ``position`` seconds in the active track."""
await self._async_run(
self.coordinator.client.seek, self._player_id, int(position)
)
async def async_media_next_track(self) -> None:
"""Skip to the next track on the active card."""
await self._async_run(self.coordinator.client.next_track, self._player_id)
async def async_media_previous_track(self) -> None:
"""Skip to the previous track on the active card."""
await self._async_run(self.coordinator.client.previous_track, self._player_id)
async def _async_run(
self, func: Callable[..., Awaitable[Any]], /, *args: Any
) -> None:
"""Await a Yoto command and surface failures as HA errors."""
try:
await func(*args)
except YotoError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -1,85 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register custom service actions.
appropriate-polling:
status: done
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
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 register custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Real-time updates are dispatched through the coordinator, not via per-entity event subscriptions.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not register custom service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category:
status: exempt
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
entity-translations:
status: exempt
comment: The media_player uses the device name; no translatable strings yet.
exception-translations: done
icon-translations:
status: exempt
comment: No custom icon translations are needed yet.
reconfiguration-flow:
status: exempt
comment: Authorization is the only configuration; reauth covers re-linking the account.
repair-issues:
status: exempt
comment: No repair issues are raised yet.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -1,44 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"oauth_discovery": {
"description": "Home Assistant has found a Yoto player on your network. Press **Submit** to continue setting up Yoto."
},
"pick_implementation": {
"data": {
"implementation": "[%key:common::config_flow::data::implementation%]"
},
"data_description": {
"implementation": "[%key:common::config_flow::description::implementation%]"
},
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"command_failed": {
"message": "Yoto command failed: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_error": {
"message": "Error communicating with Yoto: {error}"
}
}
}
@@ -20,4 +20,3 @@ ATTR_DESCRIPTION = "description"
ATTR_THUMBNAIL = "thumbnail"
ATTR_VIDEO_ID = "video_id"
ATTR_PUBLISHED_AT = "published_at"
ATTR_VIDEO_COUNT = "video_count"
@@ -21,7 +21,6 @@ from .const import (
ATTR_THUMBNAIL,
ATTR_TITLE,
ATTR_TOTAL_VIEWS,
ATTR_VIDEO_COUNT,
ATTR_VIDEO_ID,
CONF_CHANNELS,
DOMAIN,
@@ -79,7 +78,6 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
ATTR_LATEST_VIDEO: latest_video,
ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count,
ATTR_TOTAL_VIEWS: channel.statistics.view_count,
ATTR_VIDEO_COUNT: channel.statistics.video_count,
}
except UnauthorizedError as err:
raise ConfigEntryAuthFailed from err
@@ -21,7 +21,6 @@ from .const import (
ATTR_THUMBNAIL,
ATTR_TITLE,
ATTR_TOTAL_VIEWS,
ATTR_VIDEO_COUNT,
ATTR_VIDEO_ID,
)
from .coordinator import YouTubeConfigEntry
@@ -70,17 +69,6 @@ SENSOR_TYPES = [
entity_picture_fn=lambda channel: channel[ATTR_ICON],
attributes_fn=None,
),
YouTubeSensorEntityDescription(
key="videos",
translation_key="videos",
native_unit_of_measurement="videos",
state_class=SensorStateClass.TOTAL,
available_fn=lambda _: True,
value_fn=lambda channel: channel[ATTR_VIDEO_COUNT],
entity_picture_fn=lambda _: None,
attributes_fn=None,
icon="mdi:filmstrip-box-multiple",
),
]
@@ -42,7 +42,6 @@
}
},
"subscribers": { "name": "Subscribers" },
"videos": { "name": "Videos" },
"views": { "name": "Views" }
}
},
@@ -54,7 +54,7 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]):
_LOGGER,
config_entry=config_entry,
name=f"Zinvolt {battery.identifier}",
update_interval=timedelta(seconds=30),
update_interval=timedelta(minutes=5),
)
self.battery = battery
self.client = client
-1
View File
@@ -51,6 +51,5 @@ APPLICATION_CREDENTIALS = [
"xbox",
"yale",
"yolink",
"yoto",
"youtube",
]
-1
View File
@@ -860,7 +860,6 @@ FLOWS = {
"yardian",
"yeelight",
"yolink",
"yoto",
"youless",
"youtube",
"zamg",
-4
View File
@@ -1464,8 +1464,4 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "yeelight",
"hostname": "yeelink-*",
},
{
"domain": "yoto",
"hostname": "yoto-*",
},
]
@@ -8215,12 +8215,6 @@
"config_flow": true,
"iot_class": "cloud_push"
},
"yoto": {
"name": "Yoto",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push"
},
"youless": {
"name": "YouLess",
"integration_type": "device",
+6 -9
View File
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==13.8.0
aioamazondevices==13.7.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -718,7 +718,7 @@ brottsplatskartan==1.0.5
brunt==1.2.0
# homeassistant.components.bthome
bthome-ble==3.23.2
bthome-ble==3.17.0
# homeassistant.components.bt_home_hub_5
bthomehub5-devicelist==0.1.1
@@ -1362,7 +1362,7 @@ influxdb==5.3.1
infrared-protocols==5.6.0
# homeassistant.components.inkbird
inkbird-ble==1.4.4
inkbird-ble==1.4.3
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.2
@@ -2696,7 +2696,7 @@ python-picnic-api2==1.3.4
python-pooldose==0.9.1
# homeassistant.components.hr_energy_qube
python-qube-heatpump==1.11.0
python-qube-heatpump==1.10.0
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2817,7 +2817,7 @@ pyws66i==1.1
pyxeoma==1.4.2
# homeassistant.components.yardian
pyyardian==1.3.3
pyyardian==1.1.1
# homeassistant.components.qrcode
pyzbar==0.1.9
@@ -3227,7 +3227,7 @@ uhooapi==1.2.8
uiprotect==10.5.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.0
ultraheat-api==0.5.7
# homeassistant.components.unifi_discovery
unifi-discovery==1.4.0
@@ -3407,9 +3407,6 @@ yeelightsunflower==0.0.10
# homeassistant.components.yolink
yolink-api==0.6.5
# homeassistant.components.yoto
yoto-api==3.1.3
# homeassistant.components.youless
youless-api==2.2.0
+1 -5
View File
@@ -1857,8 +1857,6 @@ def import_and_test_deprecated_alias(
alias_name: str,
replacement: Any,
breaks_in_ha_version: str,
*,
replacement_name: str | None = None,
) -> None:
"""Import and test deprecated alias replaced by a value.
@@ -1868,9 +1866,7 @@ def import_and_test_deprecated_alias(
- Assert the deprecated alias is included in the modules.__dir__()
- Assert the deprecated alias is included in the modules.__all__()
"""
replacement_name = (
replacement_name or f"{replacement.__module__}.{replacement.__name__}"
)
replacement_name = f"{replacement.__module__}.{replacement.__name__}"
value = import_deprecated_constant(module, alias_name)
assert value == replacement
assert (
-3
View File
@@ -5,9 +5,6 @@ from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
AVEA_FIRMWARE_VERSION = "2.4.6 (135)"
AVEA_SERIAL_NUMBER = "FFEEDDCCBBAA"
AVEA_DISCOVERY_INFO = BluetoothServiceInfoBleak(
name="Avea Bulb",
address="AA:BB:CC:DD:EE:FF",
+1 -130
View File
@@ -7,7 +7,6 @@ from unittest.mock import MagicMock, call, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.avea.const import UNKNOWN_NAME
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_HS_COLOR,
@@ -16,9 +15,8 @@ from homeassistant.components.light import (
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import AVEA_DISCOVERY_INFO, AVEA_FIRMWARE_VERSION, AVEA_SERIAL_NUMBER
from . import AVEA_DISCOVERY_INFO
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -28,18 +26,10 @@ def mock_bulb() -> MagicMock:
"""Return a mocked Avea bulb."""
bulb = MagicMock()
bulb.name = "Unknown"
bulb.fw_version = "Unknown"
bulb.hardware_revision = "Unknown"
bulb.manufacturer_name = "Unknown"
bulb.serial_number = "Unknown"
bulb.brightness = 0
bulb.connect.return_value = True
bulb.get_brightness.return_value = 0
bulb.get_fw_version.return_value = AVEA_FIRMWARE_VERSION
bulb.get_hardware_revision.return_value = "Elgato Avea"
bulb.get_manufacturer_name.return_value = "Elgato Systems GmbH"
bulb.get_rgb.return_value = (0, 0, 0)
bulb.get_serial_number.return_value = AVEA_SERIAL_NUMBER
return bulb
@@ -75,125 +65,6 @@ async def test_init_state(
assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS]
async def test_device_info(
device_registry: dr.DeviceRegistry,
setup_integration: MagicMock,
) -> None:
"""Test the device info."""
bulb = setup_integration
device = device_registry.async_get_device(
connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)},
)
assert device is not None
assert device.name == "Bedroom"
assert device.manufacturer == "Elgato Systems GmbH"
assert device.model == "Avea"
assert device.hw_version == "Elgato Avea"
assert device.sw_version == AVEA_FIRMWARE_VERSION
assert device.serial_number == AVEA_SERIAL_NUMBER
bulb.get_manufacturer_name.assert_called_once()
bulb.get_hardware_revision.assert_called_once()
bulb.get_fw_version.assert_called_once()
bulb.get_serial_number.assert_called_once()
async def test_device_info_populates_when_connect_fails(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_bulb: MagicMock,
) -> None:
"""Test device info is populated when the shared connection fails."""
mock_bulb.connect.return_value = False
with (
patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=AVEA_DISCOVERY_INFO.device,
),
patch("homeassistant.components.avea.avea.Bulb", return_value=mock_bulb),
):
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_bulb.connect.assert_called_once()
mock_bulb.get_manufacturer_name.assert_called_once()
mock_bulb.get_hardware_revision.assert_called_once()
mock_bulb.get_fw_version.assert_called_once()
mock_bulb.get_serial_number.assert_called_once()
mock_bulb.disconnect.assert_not_called()
device = device_registry.async_get_device(
connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)},
)
assert device is not None
assert device.manufacturer == "Elgato Systems GmbH"
assert device.model == "Avea"
assert device.hw_version == "Elgato Avea"
assert device.sw_version == AVEA_FIRMWARE_VERSION
assert device.serial_number == AVEA_SERIAL_NUMBER
async def test_device_info_ignores_unknown_values(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_bulb: MagicMock,
) -> None:
"""Test unknown device info is not populated."""
mock_bulb.get_manufacturer_name.return_value = UNKNOWN_NAME
mock_bulb.get_hardware_revision.return_value = ""
mock_bulb.get_fw_version.return_value = UNKNOWN_NAME
mock_bulb.get_serial_number.return_value = ""
with (
patch(
"homeassistant.components.avea.async_ble_device_from_address",
return_value=AVEA_DISCOVERY_INFO.device,
),
patch("homeassistant.components.avea.avea.Bulb", return_value=mock_bulb),
):
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(
connections={(dr.CONNECTION_BLUETOOTH, AVEA_DISCOVERY_INFO.address)},
)
assert device is not None
assert device.manufacturer is None
assert device.model == "Avea"
assert device.hw_version is None
assert device.sw_version is None
assert device.serial_number is None
async def test_device_info_is_read_once(
hass: HomeAssistant,
setup_integration: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test device info is read once."""
bulb = setup_integration
bulb.get_manufacturer_name.reset_mock()
bulb.get_hardware_revision.reset_mock()
bulb.get_fw_version.reset_mock()
bulb.get_serial_number.reset_mock()
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
bulb.get_manufacturer_name.assert_not_called()
bulb.get_hardware_revision.assert_not_called()
bulb.get_fw_version.assert_not_called()
bulb.get_serial_number.assert_not_called()
async def test_turn_on_and_off(
hass: HomeAssistant,
setup_integration: MagicMock,
+4 -110
View File
@@ -4,7 +4,6 @@ import logging
from unittest.mock import AsyncMock, PropertyMock
import blebox_uniapi
from blebox_uniapi.cover import UnifiedCoverType
import pytest
from homeassistant.components.cover import (
@@ -53,8 +52,6 @@ def shutterbox_fixture():
has_tilt=True,
is_slider=True,
is_position_inverted=True,
cover_type=None,
tilt_only=False,
)
product = feature.product
type(product).name = PropertyMock(return_value="My shutter")
@@ -76,8 +73,6 @@ def gatebox_fixture():
has_stop=False,
is_slider=False,
is_position_inverted=False,
cover_type=None,
tilt_only=False,
)
product = feature.product
type(product).name = PropertyMock(return_value="My gatebox")
@@ -99,8 +94,6 @@ def gate_fixture():
has_stop=True,
is_slider=True,
is_position_inverted=True,
cover_type=None,
tilt_only=False,
)
product = feature.product
type(product).name = PropertyMock(return_value="My gate controller")
@@ -203,52 +196,6 @@ async def test_init_gatebox(
assert device.sw_version == "1.23"
@pytest.mark.parametrize(
("cover_type", "expected_device_class"),
[
pytest.param(UnifiedCoverType.AWNING, CoverDeviceClass.AWNING, id="awning"),
pytest.param(UnifiedCoverType.BLIND, CoverDeviceClass.BLIND, id="blind"),
pytest.param(UnifiedCoverType.CURTAIN, CoverDeviceClass.CURTAIN, id="curtain"),
pytest.param(UnifiedCoverType.DAMPER, CoverDeviceClass.DAMPER, id="damper"),
pytest.param(UnifiedCoverType.DOOR, CoverDeviceClass.DOOR, id="door"),
pytest.param(UnifiedCoverType.GARAGE, CoverDeviceClass.GARAGE, id="garage"),
pytest.param(UnifiedCoverType.GATE, CoverDeviceClass.GATE, id="gate"),
pytest.param(UnifiedCoverType.SHADE, CoverDeviceClass.SHADE, id="shade"),
pytest.param(UnifiedCoverType.SHUTTER, CoverDeviceClass.SHUTTER, id="shutter"),
pytest.param(UnifiedCoverType.WINDOW, CoverDeviceClass.WINDOW, id="window"),
],
)
async def test_device_class_from_unified_cover_type(
hass: HomeAssistant,
cover_type: UnifiedCoverType,
expected_device_class: CoverDeviceClass,
) -> None:
"""Test that device class is resolved from unified cover type when available."""
feature = mock_feature(
"covers",
blebox_uniapi.cover.Cover,
unique_id="BleBox-shutterBox-2bee34e750b8-position",
full_name="shutterBox-position",
device_class="shutter",
current=None,
tilt_current=None,
state=None,
has_stop=True,
has_tilt=False,
tilt_only=False,
is_slider=True,
is_position_inverted=True,
cover_type=cover_type,
)
product = feature.product
type(product).name = PropertyMock(return_value="My shutter")
type(product).model = PropertyMock(return_value="shutterBox")
entity_id = "cover.my_shutter_shutterbox_position"
entry = await async_setup_entity(hass, entity_id)
assert entry.original_device_class == expected_device_class
@pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"])
async def test_open(feature, hass: HomeAssistant) -> None:
"""Test cover opening."""
@@ -449,43 +396,6 @@ async def test_with_no_stop(gatebox, hass: HomeAssistant) -> None:
assert not supported_features & CoverEntityFeature.STOP
async def test_tilt_only_supported_features(shutterbox, hass: HomeAssistant) -> None:
"""Test that tilt_only removes position/open/close/stop features."""
feature_mock, entity_id = shutterbox
feature_mock.tilt_only = True
await async_setup_entity(hass, entity_id)
supported_features = hass.states.get(entity_id).attributes[ATTR_SUPPORTED_FEATURES]
assert not supported_features & CoverEntityFeature.OPEN
assert not supported_features & CoverEntityFeature.CLOSE
assert not supported_features & CoverEntityFeature.SET_POSITION
assert not supported_features & CoverEntityFeature.STOP
assert supported_features & CoverEntityFeature.OPEN_TILT
assert supported_features & CoverEntityFeature.CLOSE_TILT
assert supported_features & CoverEntityFeature.SET_TILT_POSITION
async def test_tilt_with_position_supported_features(
shutterbox, hass: HomeAssistant
) -> None:
"""Test that has_tilt without tilt_only keeps both position and tilt features."""
await async_setup_entity(hass, shutterbox[1])
supported_features = hass.states.get(shutterbox[1]).attributes[
ATTR_SUPPORTED_FEATURES
]
assert supported_features & CoverEntityFeature.OPEN
assert supported_features & CoverEntityFeature.CLOSE
assert supported_features & CoverEntityFeature.SET_POSITION
assert supported_features & CoverEntityFeature.STOP
assert supported_features & CoverEntityFeature.OPEN_TILT
assert supported_features & CoverEntityFeature.CLOSE_TILT
assert supported_features & CoverEntityFeature.SET_TILT_POSITION
@pytest.mark.parametrize("feature", ALL_COVER_FIXTURES, indirect=["feature"])
async def test_update_failure(
feature, hass: HomeAssistant, caplog: pytest.LogCaptureFixture
@@ -587,29 +497,15 @@ async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None:
assert hass.states.get(entity_id).state == CoverState.OPENING
@pytest.mark.parametrize(
("is_tilt_180", "expected_tilt_position", "expected_tilt_reported"),
[
pytest.param(False, 0, 100, id="tilt_90"),
pytest.param(True, 50, 50, id="tilt_180"),
],
)
async def test_open_tilt(
shutterbox,
hass: HomeAssistant,
is_tilt_180: bool,
expected_tilt_position: int,
expected_tilt_reported: int,
) -> None:
"""Test opening tilt for 90-degree and 180-degree tilt shutters."""
async def test_open_tilt(shutterbox, hass: HomeAssistant) -> None:
"""Test closing tilt."""
feature_mock, entity_id = shutterbox
feature_mock.is_tilt_180 = is_tilt_180
def initial_update():
feature_mock.tilt_current = 100
def set_tilt_position(tilt_position):
assert tilt_position == expected_tilt_position
assert tilt_position == 0
feature_mock.tilt_current = tilt_position
feature_mock.async_update = AsyncMock(side_effect=initial_update)
@@ -625,9 +521,7 @@ async def test_open_tilt(
blocking=True,
)
state = hass.states.get(entity_id)
assert (
state.attributes[ATTR_CURRENT_TILT_POSITION] == expected_tilt_reported
) # inverted
assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 # inverted
async def test_close_tilt(shutterbox, hass: HomeAssistant) -> None:
-421
View File
@@ -1,421 +0,0 @@
"""BleBox update entity tests."""
from datetime import timedelta
from unittest.mock import AsyncMock, PropertyMock
import blebox_uniapi.error
import blebox_uniapi.update
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.update import (
ATTR_IN_PROGRESS,
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
DOMAIN as UPDATE_DOMAIN,
SERVICE_INSTALL,
UpdateDeviceClass,
UpdateEntityFeature,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entity_component import async_update_entity
from .conftest import async_setup_entity, mock_feature
from tests.common import async_fire_time_changed
@pytest.fixture(name="firmwareupdate")
def firmwareupdate_fixture() -> tuple[blebox_uniapi.update.Update, str]:
"""Return a firmware update mock for airSensor."""
feature = mock_feature(
"updates",
blebox_uniapi.update.Update,
unique_id="BleBox-airSensor-4a3fdaad90aa-firmware",
full_name="airSensor-firmware",
installed_version="0.1",
latest_version="0.2",
)
product = feature.product
type(product).name = PropertyMock(return_value="My airSensor")
type(product).model = PropertyMock(return_value="airSensor")
return (feature, "update.my_airsensor_airsensor_firmware")
async def test_init(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test firmware update entity default state after setup."""
_, entity_id = firmwareupdate
entry = await async_setup_entity(hass, entity_id)
assert entry.unique_id == "BleBox-airSensor-4a3fdaad90aa-firmware"
state = hass.states.get(entity_id)
assert state.name == "My airSensor airSensor-firmware"
assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE
supported_features = state.attributes[ATTR_SUPPORTED_FEATURES]
assert supported_features & UpdateEntityFeature.INSTALL
assert supported_features & UpdateEntityFeature.PROGRESS
assert state.attributes[ATTR_INSTALLED_VERSION] == "0.1"
assert state.attributes[ATTR_LATEST_VERSION] == "0.2"
assert state.attributes[ATTR_IN_PROGRESS] is False
assert state.state == STATE_ON
device = device_registry.async_get(entry.device_id)
assert device.name == "My airSensor"
assert device.identifiers == {("blebox", "abcd0123ef5678")}
assert device.manufacturer == "BleBox"
assert device.model == "airSensor"
async def test_update(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that async_update refreshes versions and syncs sw_version to device registry."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
entry = await async_setup_entity(hass, entity_id)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_INSTALLED_VERSION] == "0.1"
assert state.attributes[ATTR_LATEST_VERSION] == "0.2"
def second_update() -> None:
feature_mock.installed_version = "0.2"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=second_update)
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_INSTALLED_VERSION] == "0.2"
assert state.attributes[ATTR_LATEST_VERSION] == "0.2"
assert state.state == STATE_OFF
device = device_registry.async_get(entry.device_id)
assert device.sw_version == "0.2"
async def test_update_error(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a failed async_update raises HomeAssistantError."""
feature_mock, entity_id = firmwareupdate
await async_setup_entity(hass, entity_id)
feature_mock.async_update = AsyncMock(
side_effect=blebox_uniapi.error.ClientError("connection refused")
)
await async_update_entity(hass, entity_id)
await hass.async_block_till_done()
assert "HomeAssistantError" in caplog.text
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_install(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
) -> None:
"""Test that async_install triggers feature install and sets in_progress."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is False
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
feature_mock.async_install.assert_awaited_once()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is True
assert state.attributes[ATTR_INSTALLED_VERSION] == "0.1"
async def test_install_error(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that an install failure clears in_progress and raises HomeAssistantError."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
feature_mock.async_install = AsyncMock(
side_effect=blebox_uniapi.error.ClientError("install failed")
)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is False
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
feature_mock.async_update.assert_called_once()
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_poll_until_updated_success(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that the poll timer resolves in_progress once the version changes."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
entry = await async_setup_entity(hass, entity_id)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
def poll_update() -> None:
feature_mock.installed_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=poll_update)
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is False
assert state.attributes[ATTR_INSTALLED_VERSION] == "0.2"
assert state.state == STATE_OFF
device = device_registry.async_get(entry.device_id)
assert device.sw_version == "0.2"
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_poll_connection_error(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that ConnectionError during poll reschedules the next poll without updating sw_version."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
entry = await async_setup_entity(hass, entity_id)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
feature_mock.async_update = AsyncMock(
side_effect=blebox_uniapi.error.ConnectionError
)
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is True
device = device_registry.async_get(entry.device_id)
assert device.sw_version == "0.1"
def recovery_update() -> None:
feature_mock.installed_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=recovery_update)
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes[ATTR_IN_PROGRESS] is False
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_poll_max_attempts(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that in_progress is cleared after _MAX_POLL_ATTEMPTS polls."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
feature_mock.async_update = AsyncMock(
side_effect=blebox_uniapi.error.ConnectionError
)
for _ in range(29):
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is False
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_poll_other_error(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a non-connection Error during poll cancels in_progress immediately."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
feature_mock.async_update = AsyncMock(
side_effect=blebox_uniapi.error.HttpError("500", "server error")
)
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is False
@pytest.mark.freeze_time("2026-05-21 00:00:00")
async def test_remove_cancels_poll(
firmwareupdate: tuple[blebox_uniapi.update.Update, str],
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that removing the entity cancels the pending poll timer."""
feature_mock, entity_id = firmwareupdate
def initial_update() -> None:
feature_mock.installed_version = "0.1"
feature_mock.latest_version = "0.2"
feature_mock.async_update = AsyncMock(side_effect=initial_update)
await async_setup_entity(hass, entity_id)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] is True
registry_entry = entity_registry.async_get(entity_id)
config_entry = hass.config_entries.async_get_entry(registry_entry.config_entry_id)
call_count_before = feature_mock.async_update.await_count
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
freezer.tick(timedelta(seconds=11))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert feature_mock.async_update.await_count == call_count_before
-20
View File
@@ -54,26 +54,6 @@ from tests.components.bluetooth import (
}
],
),
(
"A4:C1:38:8D:18:B2",
make_bthome_v2_adv(
"A4:C1:38:8D:18:B2",
b"\x40\x3b\x00\x01\x3b\x00\x02",
),
None,
[
{
"entity": "event.test_device_18b2_command_1",
ATTR_FRIENDLY_NAME: "Test Device 18B2 Command 1",
ATTR_EVENT_TYPE: "on",
},
{
"entity": "event.test_device_18b2_command_2",
ATTR_FRIENDLY_NAME: "Test Device 18B2 Command 2",
ATTR_EVENT_TYPE: "toggle",
},
],
),
],
)
async def test_v2_events(
-31
View File
@@ -1090,37 +1090,6 @@ async def test_v1_sensors(
},
],
),
(
"A4:C1:38:8D:18:B2",
make_bthome_v2_adv(
"A4:C1:38:8D:18:B2",
b"\x40\x64\x42",
),
None,
[
{
"sensor_entity": "sensor.test_device_18b2_light_level",
"friendly_name": "Test Device 18B2 Light level",
"state_class": "measurement",
"expected_state": "66",
},
],
),
(
"A4:C1:38:8D:18:B2",
make_bthome_v2_adv(
"A4:C1:38:8D:18:B2",
b"\x40\x65\x07",
),
None,
[
{
"sensor_entity": "sensor.test_device_18b2_settings_revision",
"friendly_name": "Test Device 18B2 Settings revision",
"expected_state": "7",
},
],
),
],
)
async def test_v2_sensors(
@@ -11,12 +11,14 @@ from homeassistant.components.device_tracker import (
ATTR_IP,
ATTR_MAC,
ATTR_SOURCE_TYPE,
CONNECTED_DEVICE_REGISTERED,
DOMAIN,
SourceType,
)
from homeassistant.components.device_tracker.config_entry import (
CONNECTED_DEVICE_REGISTERED,
BaseScannerEntity,
BaseTrackerEntity,
ScannerEntity,
SourceType,
TrackerEntity,
)
from homeassistant.components.zone import ATTR_PASSIVE, ATTR_RADIUS
@@ -1,68 +0,0 @@
"""Test deprecation classes."""
from typing import Any
import pytest
from homeassistant.components.device_tracker import (
BaseTrackerEntity,
ScannerEntity,
SourceType,
TrackerEntity,
TrackerEntityDescription,
config_entry,
)
from tests.common import help_test_all, import_and_test_deprecated_alias
def test_all() -> None:
"""Test module.__all__ is correctly set."""
help_test_all(config_entry)
@pytest.mark.parametrize(
("alias_name", "replacement", "replacement_name"),
[
(
"BaseTrackerEntity",
BaseTrackerEntity,
"homeassistant.components.device_tracker.BaseTrackerEntity",
),
(
"ScannerEntity",
ScannerEntity,
"homeassistant.components.device_tracker.ScannerEntity",
),
(
"SourceType",
SourceType,
"homeassistant.components.device_tracker.SourceType",
),
(
"TrackerEntity",
TrackerEntity,
"homeassistant.components.device_tracker.TrackerEntity",
),
(
"TrackerEntityDescription",
TrackerEntityDescription,
"homeassistant.components.device_tracker.TrackerEntityDescription",
),
],
)
def test_deprecated_config_entry_aliases(
caplog: pytest.LogCaptureFixture,
alias_name: str,
replacement: Any,
replacement_name: str,
) -> None:
"""Test deprecated config_entry aliases."""
import_and_test_deprecated_alias(
caplog,
config_entry,
alias_name,
replacement,
"2027.6",
replacement_name=replacement_name,
)
+2 -1
View File
@@ -425,7 +425,8 @@ async def test_multiple_devices(hass: HomeAssistant) -> None:
"sense_energy.SenseLink",
return_value=Mock(start=AsyncMock(), close=AsyncMock()),
):
assert await async_setup_component(hass, DOMAIN, CONFIG) is True
# pylint: disable-next=home-assistant-tests-direct-async-setup
assert await emulated_kasa.async_setup(hass, CONFIG) is True
await hass.async_block_till_done()
await emulated_kasa.validate_configs(hass, config)
+1 -1
View File
@@ -21,7 +21,7 @@ API_KEY = (
SITE_UUID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
SITE_DATA = {"title": "Test Ghost", "url": API_URL, "site_uuid": SITE_UUID}
POSTS_DATA = {"published": 42, "drafts": 5, "scheduled": 2}
MEMBERS_DATA = {"total": 1025, "paid": 100, "free": 850, "comped": 50, "gift": 25}
MEMBERS_DATA = {"total": 1000, "paid": 100, "free": 850, "comped": 50}
LATEST_POST_DATA = {
"title": "Latest Post",
"slug": "latest-post",
@@ -31,9 +31,8 @@
'members': dict({
'comped': 50,
'free': 850,
'gift': 25,
'paid': 100,
'total': 1025,
'total': 1000,
}),
'mrr': dict({
'usd': 5000,
@@ -216,59 +216,6 @@
'state': '850',
})
# ---
# name: test_sensor_entities[sensor.test_ghost_gift_members-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'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.test_ghost_gift_members',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Gift members',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Gift members',
'platform': 'ghost',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gift_members',
'unique_id': 'a1b2c3d4-e5f6-7890-abcd-ef1234567890_gift_members',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_entities[sensor.test_ghost_gift_members-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Ghost Gift members',
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_ghost_gift_members',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '25',
})
# ---
# name: test_sensor_entities[sensor.test_ghost_latest_email-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -1059,7 +1006,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1025',
'state': '1000',
})
# ---
# name: test_sensor_entities[sensor.test_ghost_weekly_subscribers-entry]
+1 -1
View File
@@ -140,7 +140,7 @@ async def test_entities_unavailable_on_update_failure(
state = hass.states.get("sensor.test_ghost_total_members")
assert state is not None
assert state.state == "1025"
assert state.state == "1000"
mock_ghost_api.get_site.side_effect = GhostError("Update failed")

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