mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 18:55:09 +02:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b849cb58 | |||
| ba5855f5d2 | |||
| 4b04006302 | |||
| 430e03f299 | |||
| 7a2422013c | |||
| c906dc3d0c | |||
| f2fa25d449 | |||
| 0426f9beb6 | |||
| b6f0ca13f9 | |||
| 83e8f4991c | |||
| 3b38208e07 | |||
| 1a15f925a0 | |||
| 10d944eab7 | |||
| 1f873927aa | |||
| fe071ff66b | |||
| e4b79d4f3d | |||
| f6d4d0289e | |||
| 3089f3cc06 | |||
| e24f35473c | |||
| 1da605230d | |||
| fd572d83b7 | |||
| 305d4429ec | |||
| b95a3f5b2d | |||
| 4e986b181b | |||
| 65c074af9a | |||
| 58eae0b815 | |||
| c201c62b3d | |||
| 8b9b21c006 | |||
| b9c00dd82b | |||
| 910b87b847 | |||
| e37459c16b | |||
| c347afe28d | |||
| c8270fcb91 | |||
| ed399a6d14 | |||
| afa01d3d8c | |||
| ba03aaa2fa | |||
| 33f3640f66 | |||
| 46fc47bcdf | |||
| 71ec3c31fa | |||
| 2d54070cab | |||
| 67e4f04f09 | |||
| 78db1e3407 | |||
| 2368a3614d | |||
| 5053392cf2 | |||
| 6ec11460ed | |||
| 975e30c048 | |||
| 7655cb0fc6 | |||
| 7566839e9d | |||
| 7db5e82f58 | |||
| 7e67c53417 | |||
| 89fb856302 | |||
| a2fbd2b1ea | |||
| 231ed34133 | |||
| 6cff433b2e | |||
| eca83fb7b1 | |||
| 2c5adaec5c | |||
| 5d75f1c33b | |||
| d628d2314e | |||
| a9547ec349 | |||
| 2ec637df84 | |||
| 4f50ee5675 | |||
| 0faf96b983 | |||
| c3dacbc601 | |||
| 2659484000 | |||
| 6830ca75f5 | |||
| 38b4184dc3 | |||
| cfde7975d8 |
Generated
+2
@@ -2056,6 +2056,8 @@ 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
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
"requirements": ["aioamazondevices==13.8.0"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -19,6 +20,7 @@ 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,
|
||||
@@ -27,7 +29,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, UNKNOWN_NAME
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, MODEL, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
@@ -42,6 +44,13 @@ 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)
|
||||
@@ -96,7 +105,8 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities(
|
||||
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
|
||||
[AveaLight(entry.runtime_data, entry.data[CONF_ADDRESS])],
|
||||
update_before_add=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -180,14 +190,42 @@ 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, entry_title: str) -> None:
|
||||
def __init__(self, light: avea.Bulb, address: str) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = entry_title
|
||||
self._attr_unique_id = address
|
||||
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."""
|
||||
@@ -214,6 +252,8 @@ 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,6 +32,7 @@ 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
|
||||
from blebox_uniapi.cover import BleboxCoverState, UnifiedCoverType
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
@@ -25,6 +25,19 @@ 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
|
||||
@@ -59,7 +72,6 @@ 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
|
||||
)
|
||||
@@ -76,6 +88,21 @@ 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."""
|
||||
@@ -118,7 +145,8 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity):
|
||||
|
||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully open the cover tilt."""
|
||||
await self._feature.async_set_tilt_position(0)
|
||||
position = 50 if self._feature.is_tilt_180 else 0
|
||||
await self._feature.async_set_tilt_position(position)
|
||||
|
||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||
"""Fully close the cover tilt."""
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""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()
|
||||
@@ -22,6 +22,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
@@ -40,7 +41,6 @@ from .const import (
|
||||
CONF_DETAILS,
|
||||
CONF_MODE,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -22,9 +22,6 @@ CONF_PASSIVE = "passive"
|
||||
|
||||
DEFAULT_MODE = BluetoothScanningMode.AUTO.value
|
||||
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||
|
||||
@@ -21,7 +21,11 @@ from habluetooth import (
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
|
||||
from homeassistant.const import (
|
||||
CONF_SOURCE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_LOGGING_CHANGED,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -33,7 +37,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util.package import is_docker_env
|
||||
|
||||
from .const import (
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.11",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,6 +28,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
EVENT_CLASS,
|
||||
EVENT_CLASS_BUTTON,
|
||||
EVENT_CLASS_COMMAND,
|
||||
EVENT_CLASS_DIMMER,
|
||||
EVENT_TYPE,
|
||||
)
|
||||
@@ -43,6 +44,7 @@ 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,6 +16,7 @@ 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,
|
||||
@@ -43,6 +44,11 @@ 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.17.0"]
|
||||
"requirements": ["bthome-ble==3.23.2"]
|
||||
}
|
||||
|
||||
@@ -192,6 +192,12 @@ 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}",
|
||||
@@ -287,6 +293,12 @@ 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,13 +36,19 @@
|
||||
"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}\""
|
||||
}
|
||||
},
|
||||
@@ -68,6 +74,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
@@ -98,6 +117,9 @@
|
||||
"gyroscope": {
|
||||
"name": "Gyroscope"
|
||||
},
|
||||
"light_level": {
|
||||
"name": "Light level"
|
||||
},
|
||||
"packet_id": {
|
||||
"name": "Packet ID"
|
||||
},
|
||||
@@ -110,6 +132,9 @@
|
||||
"rotational_speed": {
|
||||
"name": "Rotational speed"
|
||||
},
|
||||
"settings_revision": {
|
||||
"name": "Settings revision"
|
||||
},
|
||||
"text": {
|
||||
"name": "Text"
|
||||
},
|
||||
|
||||
@@ -60,7 +60,9 @@ class CheckConfigView(HomeAssistantView):
|
||||
vol.Optional("location_name"): str,
|
||||
vol.Optional("longitude"): cv.longitude,
|
||||
vol.Optional("radius"): cv.positive_int,
|
||||
vol.Optional("time_zone"): cv.time_zone,
|
||||
# Validated by async_set_time_zone in the executor to avoid
|
||||
# blocking I/O loading zoneinfo data on the event loop.
|
||||
vol.Optional("time_zone"): str,
|
||||
vol.Optional("update_units"): bool,
|
||||
vol.Optional("unit_system"): unit_system.validate_unit_system,
|
||||
}
|
||||
|
||||
@@ -3,23 +3,14 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_HOME
|
||||
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,
|
||||
@@ -45,6 +36,14 @@ 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,
|
||||
@@ -60,6 +59,8 @@ 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."""
|
||||
@@ -108,3 +109,23 @@ 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,520 +1,45 @@
|
||||
"""Code to set up a device tracker platform using a config entry."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
from functools import partial
|
||||
|
||||
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 homeassistant.helpers.deprecation import (
|
||||
DeprecatedAlias,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN)
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
|
||||
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
|
||||
from . import (
|
||||
BaseTrackerEntity as _BaseTrackerEntity,
|
||||
ScannerEntity as _ScannerEntity,
|
||||
SourceType as _SourceType,
|
||||
TrackerEntity as _TrackerEntity,
|
||||
TrackerEntityDescription as _TrackerEntityDescription,
|
||||
)
|
||||
|
||||
_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"
|
||||
)
|
||||
|
||||
# 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())
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""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
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.7",
|
||||
"aiodiscover==3.2.3",
|
||||
"aiodiscover==3.2.4",
|
||||
"cached-ipaddress==1.1.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.selector import SerialPortSelector
|
||||
|
||||
from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SERIAL_PORT): str,
|
||||
vol.Required(CONF_SERIAL_PORT): SerialPortSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/edl21",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sml"],
|
||||
"requirements": ["pysml==0.1.5"]
|
||||
"requirements": ["pysml==0.1.7"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for EDL21 Smart Meters."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sml import SmlGetListResponse
|
||||
@@ -29,7 +28,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import (
|
||||
CONF_SERIAL_PORT,
|
||||
@@ -39,8 +37,6 @@ from .const import (
|
||||
SIGNAL_EDL21_TELEGRAM,
|
||||
)
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
# OBIS format: A-B:C.D.E*F
|
||||
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
# A=1: Electricity
|
||||
@@ -391,8 +387,6 @@ class EDL21Entity(SensorEntity):
|
||||
self._electricity_id = electricity_id
|
||||
self._obis = obis
|
||||
self._telegram = telegram
|
||||
self._min_time = MIN_TIME_BETWEEN_UPDATES
|
||||
self._last_update = utcnow()
|
||||
self._async_remove_dispatcher = None
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{electricity_id}_{obis}"
|
||||
@@ -414,12 +408,7 @@ class EDL21Entity(SensorEntity):
|
||||
if self._telegram == telegram:
|
||||
return
|
||||
|
||||
now = utcnow()
|
||||
if now - self._last_update < self._min_time:
|
||||
return
|
||||
|
||||
self._telegram = telegram
|
||||
self._last_update = now
|
||||
self.async_write_ha_state()
|
||||
|
||||
self._async_remove_dispatcher = async_dispatcher_connect(
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"serial_port": "[%key:common::config_flow::data::usb_path%]"
|
||||
"serial_port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"serial_port": "Serial port path to connect to"
|
||||
},
|
||||
"title": "Add your EDL21 smart meter"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Device tracker platform for fressnapf_tracker."""
|
||||
|
||||
from homeassistant.components.device_tracker.config_entry import TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
@@ -4,24 +4,14 @@ import logging
|
||||
|
||||
from bleak.backends.device import BLEDevice
|
||||
from gardena_bluetooth.client import CachedConnection, Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
)
|
||||
from gardena_bluetooth.parse import CharacteristicTime, ProductType
|
||||
from gardena_bluetooth.const import ProductType
|
||||
from gardena_bluetooth.scan import async_get_manufacturer_data
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
DeviceUnavailable,
|
||||
GardenaBluetoothConfigEntry,
|
||||
@@ -39,7 +29,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.VALVE,
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
TIMEOUT = 20.0
|
||||
DISCONNECT_DELAY = 5
|
||||
|
||||
|
||||
@@ -57,15 +46,6 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
|
||||
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
|
||||
|
||||
|
||||
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
|
||||
try:
|
||||
await client.update_timestamp(characteristics, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
@@ -73,49 +53,30 @@ async def async_setup_entry(
|
||||
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
try:
|
||||
mfg_data = await async_get_manufacturer_data({address})
|
||||
except TimeoutError as exc:
|
||||
raise ConfigEntryNotReady("Unable to find product type") from exc
|
||||
|
||||
product_type = mfg_data[address].product_type
|
||||
if product_type is ProductType.UNKNOWN:
|
||||
raise ConfigEntryNotReady("Unable to find product type")
|
||||
|
||||
client = Client(get_connection(hass, address), product_type)
|
||||
try:
|
||||
chars = await client.get_all_characteristics()
|
||||
|
||||
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
|
||||
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
|
||||
model = await client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = entry.title
|
||||
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
|
||||
name = await client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
|
||||
await _update_timestamp(client, AquaContour.unix_timestamp)
|
||||
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
await client.disconnect()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to device {address} due to {exception}"
|
||||
) from exception
|
||||
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=name,
|
||||
sw_version=sw_version,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
)
|
||||
|
||||
coordinator = GardenaBluetoothCoordinator(
|
||||
hass, entry, LOGGER, client, set(chars.keys()), device, address
|
||||
hass,
|
||||
entry,
|
||||
LOGGER,
|
||||
client,
|
||||
address,
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
return True
|
||||
|
||||
|
||||
@@ -123,7 +84,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await entry.runtime_data.async_shutdown()
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -4,17 +4,28 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from gardena_bluetooth.client import Client
|
||||
from gardena_bluetooth.const import AquaContour, DeviceConfiguration, DeviceInformation
|
||||
from gardena_bluetooth.exceptions import (
|
||||
CharacteristicNoAccess,
|
||||
CharacteristicNotFound,
|
||||
CommunicationFailure,
|
||||
GardenaBluetoothException,
|
||||
)
|
||||
from gardena_bluetooth.parse import Characteristic, CharacteristicType
|
||||
from gardena_bluetooth.parse import (
|
||||
Characteristic,
|
||||
CharacteristicTime,
|
||||
CharacteristicType,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,8 +48,6 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
config_entry: GardenaBluetoothConfigEntry,
|
||||
logger: logging.Logger,
|
||||
client: Client,
|
||||
characteristics: set[str],
|
||||
device_info: DeviceInfo,
|
||||
address: str,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
@@ -52,14 +61,63 @@ class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
self.address = address
|
||||
self.data = {}
|
||||
self.client = client
|
||||
self.characteristics = characteristics
|
||||
self.device_info = device_info
|
||||
self.characteristics: set[str] = set()
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, address)},
|
||||
connections={(dr.CONNECTION_BLUETOOTH, address)},
|
||||
name=config_entry.title,
|
||||
)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shutdown coordinator and any connection."""
|
||||
await super().async_shutdown()
|
||||
await self.client.disconnect()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator and read initial device metadata."""
|
||||
try:
|
||||
chars = await self.client.get_all_characteristics()
|
||||
|
||||
sw_version = await self.client.read_char(
|
||||
DeviceInformation.firmware_version, None
|
||||
)
|
||||
manufacturer = await self.client.read_char(
|
||||
DeviceInformation.manufacturer_name, None
|
||||
)
|
||||
model = await self.client.read_char(DeviceInformation.model_number, None)
|
||||
|
||||
name = self.config_entry.title
|
||||
name = await self.client.read_char(
|
||||
DeviceConfiguration.custom_device_name, name
|
||||
)
|
||||
name = await self.client.read_char(AquaContour.custom_device_name, name)
|
||||
|
||||
await self._update_timestamp(DeviceConfiguration.unix_timestamp)
|
||||
await self._update_timestamp(AquaContour.unix_timestamp)
|
||||
|
||||
self.characteristics = set(chars.keys())
|
||||
self.device_info = DeviceInfo(
|
||||
{
|
||||
**self.device_info,
|
||||
"name": name,
|
||||
"sw_version": sw_version,
|
||||
"manufacturer": manufacturer,
|
||||
"model": model,
|
||||
}
|
||||
)
|
||||
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
|
||||
raise UpdateFailed(
|
||||
f"Unable to set up Gardena Bluetooth device due to {exception}"
|
||||
) from exception
|
||||
|
||||
async def _update_timestamp(self, char: CharacteristicTime) -> None:
|
||||
try:
|
||||
await self.client.update_timestamp(char, dt_util.now())
|
||||
except CharacteristicNotFound:
|
||||
pass
|
||||
except CharacteristicNoAccess:
|
||||
LOGGER.debug("No access to update internal time")
|
||||
|
||||
async def _async_update_data(self) -> dict[str, bytes]:
|
||||
"""Poll the device."""
|
||||
uuids: set[str] = {
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"free_members": {
|
||||
"default": "mdi:account-outline"
|
||||
},
|
||||
"gift_members": {
|
||||
"default": "mdi:gift-outline"
|
||||
},
|
||||
"latest_email": {
|
||||
"default": "mdi:email-newsletter"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,12 @@ 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,6 +62,9 @@
|
||||
"free_members": {
|
||||
"name": "Free members"
|
||||
},
|
||||
"gift_members": {
|
||||
"name": "Gift members"
|
||||
},
|
||||
"latest_email": {
|
||||
"name": "Latest email"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.4"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not errors and login is not None:
|
||||
await self.async_set_unique_id(str(login.id))
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_API_KEY: login.apiToken},
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reauth_entry, data_updates=user_input[SECTION_REAUTH_API_KEY]
|
||||
)
|
||||
else:
|
||||
@@ -309,7 +309,7 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
if not errors and user is not None:
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
|
||||
@@ -64,10 +64,6 @@ BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = {
|
||||
key="tamper_detection",
|
||||
device_class=BinarySensorDeviceClass.TAMPER,
|
||||
),
|
||||
"Shelter Alarm": BinarySensorEntityDescription(
|
||||
key="shelter_alarm",
|
||||
translation_key="shelter_alarm",
|
||||
),
|
||||
"Disk Full": BinarySensorEntityDescription(
|
||||
key="disk_full",
|
||||
translation_key="disk_full",
|
||||
|
||||
@@ -84,9 +84,6 @@
|
||||
"scene_change_detection": {
|
||||
"name": "Scene change detection"
|
||||
},
|
||||
"shelter_alarm": {
|
||||
"name": "Shelter alarm"
|
||||
},
|
||||
"unattended_baggage": {
|
||||
"name": "Unattended baggage"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "homewizard"
|
||||
ISSUE_BATTERY_MODE_CLOUD_DISABLED = "battery_mode_cloud_disabled"
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.NUMBER,
|
||||
@@ -22,3 +23,8 @@ 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,14 +2,21 @@
|
||||
|
||||
from homewizard_energy import HomeWizardEnergy
|
||||
from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError
|
||||
from homewizard_energy.models import CombinedModels as DeviceResponseEntry
|
||||
from homewizard_energy.models import Batteries, 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, LOGGER, UPDATE_INTERVAL
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
ISSUE_BATTERY_MODE_CLOUD_DISABLED,
|
||||
LOGGER,
|
||||
UPDATE_INTERVAL,
|
||||
battery_mode_cloud_issue_id,
|
||||
)
|
||||
|
||||
type HomeWizardConfigEntry = ConfigEntry[HWEnergyDeviceUpdateCoordinator]
|
||||
|
||||
@@ -38,6 +45,34 @@ 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:
|
||||
@@ -70,6 +105,7 @@ 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,6 +1,6 @@
|
||||
"""Base entity for the HomeWizard integration."""
|
||||
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS
|
||||
from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_SERIAL_NUMBER
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -28,3 +28,4 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]):
|
||||
(CONNECTION_NETWORK_MAC, serial_number)
|
||||
}
|
||||
self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)}
|
||||
self._attr_device_info[ATTR_SERIAL_NUMBER] = serial_number
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Repairs for HomeWizard integration."""
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homewizard_energy.errors import RequestError
|
||||
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
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):
|
||||
@@ -59,18 +66,54 @@ 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."""
|
||||
assert data is not None
|
||||
assert isinstance(data["entry_id"], str)
|
||||
if data is None or not isinstance(entry_id := data.get("entry_id"), str):
|
||||
return ConfirmRepairFlow()
|
||||
|
||||
if issue_id.startswith("migrate_to_v2_api_") and (
|
||||
entry := hass.config_entries.async_get_entry(data["entry_id"])
|
||||
entry := hass.config_entries.async_get_entry(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,6 +632,32 @@ 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,6 +106,12 @@
|
||||
"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"
|
||||
},
|
||||
@@ -182,6 +188,20 @@
|
||||
}
|
||||
},
|
||||
"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.10.0"]
|
||||
"requirements": ["python-qube-heatpump==1.11.0"]
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltBattery.PACK_3_TEMPERATURE,
|
||||
IndevoltBattery.PACK_4_TEMPERATURE,
|
||||
IndevoltBattery.PACK_5_TEMPERATURE,
|
||||
IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_2_MOS_TEMPERATURE,
|
||||
IndevoltBattery.PACK_3_MOS_TEMPERATURE,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["indevolt-api==1.8.1"],
|
||||
"requirements": ["indevolt-api==1.8.2"],
|
||||
"zeroconf": [{ "name": "igen_fw*", "type": "_http._tcp.local." }]
|
||||
}
|
||||
|
||||
@@ -612,6 +612,16 @@ SENSORS: Final = (
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
# Battery Pack MOS Temperature
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.MAIN_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
translation_key="main_mos_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.PACK_1_MOS_TEMPERATURE,
|
||||
generation=(2,),
|
||||
|
||||
@@ -295,6 +295,9 @@
|
||||
"main_current": {
|
||||
"name": "Main current"
|
||||
},
|
||||
"main_mos_temperature": {
|
||||
"name": "Main MOS temperature"
|
||||
},
|
||||
"main_serial_number": {
|
||||
"name": "Main serial number"
|
||||
},
|
||||
|
||||
@@ -63,5 +63,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/inkbird",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["inkbird-ble==1.4.2"]
|
||||
"requirements": ["inkbird-ble==1.4.4"]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ from kiosker import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_API_TOKEN, DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
|
||||
from .const import DEFAULT_SSL, DEFAULT_SSL_VERIFY, DOMAIN, PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
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_HOST, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_API_TOKEN, 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 CONF_API_TOKEN, DOMAIN, POLL_INTERVAL, PORT
|
||||
from .const import DOMAIN, POLL_INTERVAL, PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import serial
|
||||
import serialx
|
||||
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 (TimeoutError, serial.SerialException) as err:
|
||||
except (OSError, TimeoutError, serialx.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 serial
|
||||
import serialx
|
||||
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 (FileNotFoundError, serial.SerialException) as err:
|
||||
except (OSError, TimeoutError, serialx.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.5.7"]
|
||||
"requirements": ["ultraheat-api==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -175,6 +175,8 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
|
||||
disinfecting = 285
|
||||
flex_load_active = 11047
|
||||
automatic_start = 11044
|
||||
paused = 11052
|
||||
cancelled = 11053
|
||||
|
||||
|
||||
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
|
||||
|
||||
@@ -984,6 +984,7 @@
|
||||
"blocked_brushes": "Brushes blocked",
|
||||
"blocked_drive_wheels": "Drive wheels blocked",
|
||||
"blocked_front_wheel": "Front wheel blocked",
|
||||
"cancelled": "Cancelled",
|
||||
"cleaning": "Cleaning",
|
||||
"comfort_cooling": "Comfort cooling",
|
||||
"cooling_down": "Cooling down",
|
||||
@@ -1026,6 +1027,7 @@
|
||||
"normal": "Normal",
|
||||
"normal_plus": "Normal plus",
|
||||
"not_running": "Not running",
|
||||
"paused": "Paused",
|
||||
"perfect_dry_active": "PerfectDry active",
|
||||
"pre_brewing": "Pre-brewing",
|
||||
"pre_dishwash": "Pre-cleaning",
|
||||
|
||||
@@ -10,10 +10,12 @@ import voluptuous as vol
|
||||
from homeassistant.components.device_tracker import (
|
||||
ATTR_BATTERY,
|
||||
ATTR_GPS,
|
||||
ATTR_IN_ZONES,
|
||||
ATTR_LOCATION_NAME,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.components.zone import (
|
||||
DOMAIN as ZONE_DOMAIN,
|
||||
ENTITY_ID_FORMAT as ZONE_ENTITY_ID_FORMAT,
|
||||
HOME_ZONE,
|
||||
)
|
||||
@@ -59,6 +61,7 @@ LOCATION_UPDATE_SCHEMA = vol.All(
|
||||
vol.Optional(ATTR_ALTITUDE): vol.Coerce(float),
|
||||
vol.Optional(ATTR_COURSE): cv.positive_int,
|
||||
vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int,
|
||||
vol.Optional(ATTR_IN_ZONES): cv.entities_domain(ZONE_DOMAIN),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -126,6 +129,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
|
||||
|
||||
return attrs
|
||||
|
||||
@property
|
||||
def in_zones(self) -> list[str] | None:
|
||||
"""Return the zones the device is currently in."""
|
||||
return self._data.get(ATTR_IN_ZONES)
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the gps accuracy of the device."""
|
||||
@@ -150,6 +158,11 @@ class MobileAppEntity(TrackerEntity, RestoreEntity):
|
||||
@property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
if ATTR_IN_ZONES in self._data:
|
||||
# New app sends in_zones as well as location_name. Prioritize in_zones
|
||||
# and only use location_name for backwards compatibility with old
|
||||
# app versions.
|
||||
return None
|
||||
if location_name := self._data.get(ATTR_LOCATION_NAME):
|
||||
if location_name == HOME_ZONE:
|
||||
return STATE_HOME
|
||||
|
||||
@@ -125,8 +125,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity):
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove exprire triggers."""
|
||||
# Clean up expire 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,6 +354,7 @@ from .const import (
|
||||
CONF_TILT_STATE_OPTIMISTIC,
|
||||
CONF_TILT_STATUS_TEMPLATE,
|
||||
CONF_TILT_STATUS_TOPIC,
|
||||
CONF_TIMEZONE,
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TRANSITION,
|
||||
CONF_TRANSPORT,
|
||||
@@ -461,6 +462,8 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.DATE,
|
||||
Platform.DATETIME,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
Platform.LIGHT,
|
||||
@@ -472,6 +475,7 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
Platform.TIME,
|
||||
Platform.VALVE,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
@@ -485,6 +489,10 @@ 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/"
|
||||
@@ -504,6 +512,7 @@ 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
|
||||
@@ -1237,6 +1246,8 @@ 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,
|
||||
@@ -1248,6 +1259,7 @@ 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,
|
||||
}
|
||||
@@ -1413,6 +1425,8 @@ PLATFORM_ENTITY_FIELDS: dict[Platform, dict[str, PlatformField]] = {
|
||||
required=False,
|
||||
),
|
||||
},
|
||||
Platform.DATE: {},
|
||||
Platform.DATETIME: {},
|
||||
Platform.FAN: {
|
||||
"fan_feature_speed": PlatformField(
|
||||
selector=BOOLEAN_SELECTOR,
|
||||
@@ -1517,6 +1531,7 @@ 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
|
||||
@@ -2366,6 +2381,61 @@ 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,
|
||||
@@ -3473,6 +3543,33 @@ 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,6 +56,7 @@ 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,6 +27,7 @@ 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
|
||||
@@ -40,8 +41,6 @@ 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,6 +378,7 @@
|
||||
"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"
|
||||
@@ -430,6 +431,7 @@
|
||||
"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)"
|
||||
@@ -1468,6 +1470,8 @@
|
||||
"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%]",
|
||||
@@ -1479,6 +1483,7 @@
|
||||
"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,12 +98,11 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity):
|
||||
return
|
||||
|
||||
if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data:
|
||||
self._attr_current_option = (
|
||||
self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get(
|
||||
data["schedule_id"]
|
||||
)
|
||||
).name
|
||||
self.async_write_ha_state()
|
||||
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()
|
||||
|
||||
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.config_entry import TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"wrong_username": account.username,
|
||||
},
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
@@ -366,7 +366,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
@@ -376,7 +376,7 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_USERNAME: account.username,
|
||||
}
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: account.username,
|
||||
|
||||
@@ -27,6 +27,7 @@ DEVICE_SUPPORT = {
|
||||
"3B": (),
|
||||
"42": (),
|
||||
"7E": ("EDS0065", "EDS0066", "EDS0068"),
|
||||
"81": (),
|
||||
"A6": (),
|
||||
"EF": ("HB_HUB", "HB_MOISTURE_METER", "HobbyBoards_EF"),
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ONVIFConfigEntry) -> boo
|
||||
await async_populate_options(hass, entry)
|
||||
|
||||
device = ONVIFDevice(hass, entry)
|
||||
camera_address = f"{device.device.host}:{device.device.port}"
|
||||
camera_address = f"{device.host}:{device.port}"
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
# Register cleanup callback for device
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.0.4",
|
||||
"onvif-zeep-async==4.1.0",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
|
||||
@@ -88,9 +88,13 @@ 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=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
parameters=schema,
|
||||
)
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
|
||||
@@ -6,7 +6,11 @@ from homeassistant.core import HomeAssistant
|
||||
from .coordinator import OumanEh800ConfigEntry, OumanEh800Coordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.VALVE,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""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,6 +4,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from ouman_eh_800_api import (
|
||||
ControllableEndpoint,
|
||||
L1BaseEndpoints,
|
||||
L2BaseEndpoints,
|
||||
OumanClientAuthenticationError,
|
||||
@@ -17,7 +18,11 @@ 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
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -97,6 +102,21 @@ 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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,6 +31,76 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
@@ -49,6 +119,9 @@
|
||||
"name": "Supply water temperature setpoint"
|
||||
},
|
||||
"valve_position": { "name": "Valve position" }
|
||||
},
|
||||
"valve": {
|
||||
"valve_position_setpoint": { "name": "Valve position setpoint" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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.config_entry import TrackerEntity
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_NPSSO: npsso},
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.2.4"]
|
||||
"requirements": ["ical==13.2.5"]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.const import (
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -1278,6 +1279,35 @@ 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,6 +988,15 @@
|
||||
"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
|
||||
from .const import CONF_HAS_PWD, DEFAULT_TIMEOUT
|
||||
from .coordinator import (
|
||||
SolarLogBasicDataCoordinator,
|
||||
SolarlogConfigEntry,
|
||||
@@ -56,21 +56,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) ->
|
||||
|
||||
entry.runtime_data = solarLogData
|
||||
|
||||
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)
|
||||
_LOGGER.debug(
|
||||
"Basic coordinator setup successful, extended data available: %s",
|
||||
solarLogData.api.extended_data,
|
||||
)
|
||||
|
||||
longtime_coordinator = SolarLogLongtimeDataCoordinator(
|
||||
hass, entry, solarlog, timeout
|
||||
)
|
||||
entry.runtime_data.longtime_data_coordinator = longtime_coordinator
|
||||
await longtime_coordinator.async_config_entry_first_refresh()
|
||||
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")
|
||||
|
||||
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
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT
|
||||
|
||||
from .const import CONF_HAS_PWD, DEFAULT_HOST, DOMAIN
|
||||
from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_TIMEOUT, DOMAIN
|
||||
|
||||
|
||||
class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -137,6 +137,7 @@ 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
|
||||
)
|
||||
@@ -145,6 +146,7 @@ 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,5 +4,6 @@ DOMAIN = "solarlog"
|
||||
|
||||
# Default config for solarlog.
|
||||
DEFAULT_HOST = "http://solar-log"
|
||||
DEFAULT_TIMEOUT = 30
|
||||
|
||||
CONF_HAS_PWD = "has_password"
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -237,6 +238,37 @@ 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,9 +86,7 @@ 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,6 +153,7 @@ 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 DOMAIN, PLATFORMS
|
||||
from .const import CONF_SECUREON_PASSWORD, DOMAIN, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
}
|
||||
@@ -34,7 +35,8 @@ 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 = call.data.get(CONF_MAC)
|
||||
mac_address: str = call.data[CONF_MAC]
|
||||
secureon_password = call.data.get(CONF_SECUREON_PASSWORD)
|
||||
broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS)
|
||||
broadcast_port = call.data.get(CONF_BROADCAST_PORT)
|
||||
|
||||
@@ -45,14 +47,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
service_kwargs["port"] = broadcast_port
|
||||
|
||||
_LOGGER.debug(
|
||||
"Send magic packet to mac %s (broadcast: %s, port: %s)",
|
||||
"Send magic packet to mac %s (secureon: %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) # type: ignore[arg-type]
|
||||
partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs)
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -13,6 +13,8 @@ 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__)
|
||||
|
||||
|
||||
@@ -25,6 +27,7 @@ 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(
|
||||
@@ -32,6 +35,7 @@ async def async_setup_entry(
|
||||
WolButton(
|
||||
name,
|
||||
mac_address,
|
||||
secureon_password,
|
||||
broadcast_address,
|
||||
broadcast_port,
|
||||
)
|
||||
@@ -48,11 +52,13 @@ 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)
|
||||
@@ -70,12 +76,17 @@ class WolButton(ButtonEntity):
|
||||
service_kwargs["port"] = self._broadcast_port
|
||||
|
||||
_LOGGER.debug(
|
||||
"Send magic packet to mac %s (broadcast: %s, port: %s)",
|
||||
"Send magic packet to mac %s (secureon: %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, self._mac_address, **service_kwargs)
|
||||
partial(wakeonlan.send_magic_packet, mac, **service_kwargs)
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
from .const import CONF_SECUREON_PASSWORD, DEFAULT_NAME, DOMAIN
|
||||
|
||||
|
||||
async def validate(
|
||||
@@ -48,6 +48,7 @@ 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,6 +6,7 @@ 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,6 +5,11 @@ 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,12 +8,14 @@
|
||||
"data": {
|
||||
"broadcast_address": "Broadcast address",
|
||||
"broadcast_port": "Broadcast port",
|
||||
"mac": "MAC address"
|
||||
"mac": "MAC address",
|
||||
"secureon_password": "SecureOn password"
|
||||
},
|
||||
"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."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,11 +28,13 @@
|
||||
"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%]"
|
||||
"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%]"
|
||||
},
|
||||
"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%]"
|
||||
"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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +54,10 @@
|
||||
"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"
|
||||
|
||||
@@ -83,9 +83,7 @@ class OAuth2FlowHandler(
|
||||
description_placeholders={"gamertag": me.people[0].gamertag}
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data=data
|
||||
)
|
||||
return self.async_update_and_abort(self._get_reauth_entry(), data=data)
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/yardian",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pyyardian==1.1.1"]
|
||||
"requirements": ["pyyardian==1.3.3"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user