mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 18:55:09 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbbcc0eba4 |
Generated
-2
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,5 @@ APPLICATION_CREDENTIALS = [
|
||||
"xbox",
|
||||
"yale",
|
||||
"yolink",
|
||||
"yoto",
|
||||
"youtube",
|
||||
]
|
||||
|
||||
Generated
-1
@@ -860,7 +860,6 @@ FLOWS = {
|
||||
"yardian",
|
||||
"yeelight",
|
||||
"yolink",
|
||||
"yoto",
|
||||
"youless",
|
||||
"youtube",
|
||||
"zamg",
|
||||
|
||||
Generated
-4
@@ -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",
|
||||
|
||||
Generated
+6
-9
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+4
-2
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user