Compare commits

..

67 Commits

Author SHA1 Message Date
Paul Bottein 71b849cb58 Add Yoto integration (#171207)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 18:35:28 +02:00
Michael Barrett ba5855f5d2 Added gift members sensor to Ghost integration (#171441) 2026-05-26 18:20:27 +02:00
Alex Romanov 4b04006302 Add test fixture for Tuya smart kettle (dft4ebatvon3ha5s) (#172260)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-26 17:57:31 +02:00
1einmal1 430e03f299 Ignore DS1420 devices in onewire (#172132) 2026-05-26 16:59:41 +02:00
J. Nick Koston 7a2422013c Bump bthome-ble to 3.23.2 and add support for light level, settings revision, and command events (#172216) 2026-05-26 16:58:18 +02:00
Ian c906dc3d0c Fix invalid schema for HassStartTimer in OpenRouter extension (#172153) 2026-05-26 16:51:11 +02:00
Matt f2fa25d449 Fix Netatmo select AttributeError when webhook schedule_id not in cache (#171914)
Signed-off-by: Matt Jones <47545907+SoundMatt@users.noreply.github.com>
2026-05-26 16:50:17 +02:00
Yardian Support 0426f9beb6 Bump yardian to v133 (#170982)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-26 16:48:39 +02:00
J. Nick Koston b6f0ca13f9 Bump inkbird-ble to 1.4.4 (#172266) 2026-05-26 16:45:17 +02:00
Michael 83e8f4991c Add SecureOn password support to Wake On Lan (#172167) 2026-05-26 16:43:14 +02:00
Max Michels 3b38208e07 Remove positional message strings when translation_key is set in tesla_fleet (#172267) 2026-05-26 16:39:06 +02:00
Arsène Reymond 1a15f925a0 Add entity_picture_local on universal media player (#164872)
Co-authored-by: Copilot <copilot@github.com>
2026-05-26 16:38:34 +02:00
Vincent Knoop Pathuis 10d944eab7 Migrate landisgyr_heat_meter to ultraheat-api 0.6.0 (serialx) (#172186)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 16:37:32 +02:00
Sören 1f873927aa Add Avea device info (#171624) 2026-05-26 16:27:07 +02:00
Chrystyan A Pulido fe071ff66b Add tests for states_in_range and int_states_in_range (#164548)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-26 16:19:56 +02:00
Joost Lekkerkerker e4b79d4f3d Don't use async_setup in vesync tests (#172257) 2026-05-26 16:17:37 +02:00
dontinelli f6d4d0289e Fix timeout increase for longtime coordinator for solarlog (#170564)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 16:11:14 +02:00
Mattie 3089f3cc06 Bump python-qube-heatpump to 1.11.0 (#172261) 2026-05-26 16:06:57 +02:00
Max Michels e24f35473c Replace duplicate constants in kiosker with homeassistant.const imports (#172263) 2026-05-26 16:05:07 +02:00
Petro31 1da605230d Move device_tracker entity classes out of device_tracker.config_entry (#171857) 2026-05-26 16:04:34 +02:00
Joost Lekkerkerker fd572d83b7 Use async_setup_component in emulated_kasa (#172256) 2026-05-26 16:03:42 +02:00
bkobus-bbx 305d4429ec Resolve cover device class from blebox unified cover type (#171174)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-26 16:03:28 +02:00
Simone Chemelli b95a3f5b2d Bump aioamazondevices to 13.8.0 (#172251) 2026-05-26 16:03:07 +02:00
Joost Lekkerkerker 4e986b181b Lower update interval for zinvolt (#171851) 2026-05-26 16:02:37 +02:00
Joost Lekkerkerker 65c074af9a Add Copper water meter sensors to SmartThings (#171848) 2026-05-26 16:01:40 +02:00
Markus Tuominen 58eae0b815 Add climate platform to Ouman EH-800 (#172163) 2026-05-26 16:01:27 +02:00
Markus Tuominen c201c62b3d Add select platform to Ouman EH-800 (#170496) 2026-05-26 15:43:19 +02:00
bkobus-bbx 8b9b21c006 Add update platform to Blebox integration (#172148) 2026-05-26 15:32:32 +02:00
Duco Sebel b9c00dd82b Generate repair when predictive mode is enabled while cloud communication is disabled in HomeWizard (#171850)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 15:28:38 +02:00
bkobus-bbx 910b87b847 Add support for 180-degree tilt in BleBox shutter covers (#172237) 2026-05-26 15:19:43 +02:00
Markus Tuominen e37459c16b Add the number platform to the Ouman EH-800 integration (#172134) 2026-05-26 16:16:12 +03:00
Nathan Osman c347afe28d Add video_count sensor to YouTube integration (#171999) 2026-05-26 15:12:50 +02:00
bkobus-bbx c8270fcb91 Add tilt-only mode support for BleBox cover entities (#172235) 2026-05-26 15:10:26 +02:00
Joost Lekkerkerker ed399a6d14 Remove internal test for ps4 (#172258) 2026-05-26 15:07:29 +02:00
Jan Bouwhuis afa01d3d8c Improve docstring and comment in mqtt code (#172246) 2026-05-26 14:36:26 +02:00
Jan Bouwhuis ba03aaa2fa Add subentry support for MQTT date, datetime and time entity platforms (#171396) 2026-05-26 14:26:30 +02:00
Duco Sebel 33f3640f66 Add HomeWizard battery group power sensor (#172248) 2026-05-26 14:25:19 +02:00
Erik Montnemery 46fc47bcdf Add explicit tests of trigger helper extract_xxx functions (#172238) 2026-05-26 14:15:07 +02:00
Markus Tuominen 71ec3c31fa Add valve platform to Ouman EH-800 (#172149) 2026-05-26 12:22:37 +02:00
Duco Sebel 2d54070cab Add HomeWizard battery group target power sensor (#172243) 2026-05-26 11:50:51 +02:00
Duco Sebel 67e4f04f09 Add serial_number to HomeWizard device registry entries (#172233) 2026-05-26 11:25:09 +02:00
Erik Montnemery 78db1e3407 Deprecate the FlowHandler show_advanced_options property (#171754)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-26 10:25:41 +02:00
Erik Montnemery 2368a3614d Remove support for advanced mode from schema config flow (#172117)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-26 09:50:55 +02:00
Erik Montnemery 5053392cf2 Add in_zones property to mobile_app device tracker (#171814) 2026-05-26 08:51:47 +02:00
Max Michels 6ec11460ed Replace duplicate constants in bluetooth with homeassistant.const imports (#172079) 2026-05-26 08:38:23 +02:00
Paul Tarjan 975e30c048 Remove unreachable Hikvision Shelter Alarm binary sensor (#172152) 2026-05-26 08:30:23 +02:00
J. Nick Koston 7655cb0fc6 Fix blocking time_zone validation in config/core/update websocket command (#172227) 2026-05-26 08:28:34 +02:00
Åke Strandberg 7566839e9d Add missing Miele program phase codes (#172144) 2026-05-26 08:28:31 +02:00
Manu 7db5e82f58 Use non-reloading entry update methods in config flow of ntfy integration (#172222) 2026-05-26 08:25:37 +02:00
Manu 7e67c53417 Use non-reloading entry update method in config flow of PlayStation Netwwork integration (#172223) 2026-05-26 08:25:20 +02:00
Manu 89fb856302 Use non-reloading entry update method in config flow of Xbox integration (#172224) 2026-05-26 08:25:07 +02:00
Manu a2fbd2b1ea Migrate EDL21 to use SerialPortSelector (#172220) 2026-05-26 08:23:34 +02:00
Manu 231ed34133 Bump pysml to 0.1.7 (#172217) 2026-05-26 08:23:05 +02:00
Manu 6cff433b2e Remove artificial throttling of push updates in EDL21 integration (#172213) 2026-05-26 08:22:57 +02:00
Joakim Plate eca83fb7b1 Switch to async_setup in coordinator for gardena setup (#172198)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-26 08:22:35 +02:00
A. Gideonse 2c5adaec5c Bump indevolt-api to 1.8.2 (#172201) 2026-05-26 08:21:56 +02:00
Manu 5d75f1c33b Use non-reloading entry update method in config flow of Habitica integration (#172225) 2026-05-26 08:20:48 +02:00
Manu d628d2314e Fix wrong integration type classification of EDL21 (#172230) 2026-05-26 08:19:46 +02:00
J. Nick Koston a9547ec349 Bump inkbird-ble to 1.4.3 (#172211) 2026-05-25 22:16:07 -05:00
J. Nick Koston 2ec637df84 Bump dbus-fast to 5.0.14 (#172215) 2026-05-25 22:15:54 -05:00
J. Nick Koston 4f50ee5675 Fix ONVIF camera_address using uninitialized inner device attribute (#172219) 2026-05-25 22:15:41 -05:00
J. Nick Koston 0faf96b983 Bump onvif-zeep-async to 4.1.0 (#172212) 2026-05-25 22:15:27 -05:00
Allen Porter c3dacbc601 Bump ical to 13.2.5 (#172214) 2026-05-25 19:23:46 -07:00
renovate[bot] 2659484000 Update uv to 0.11.15 (#172208) 2026-05-25 20:18:52 -05:00
J. Nick Koston 6830ca75f5 Bump aiodiscover to 3.2.4 (#172203) 2026-05-25 20:17:54 -05:00
J. Nick Koston 38b4184dc3 Bump inkbird-ble to 1.4.0 (#172199) 2026-05-25 19:04:41 -05:00
A. Gideonse cfde7975d8 Add main MOS temp to Indevolt (#171476) 2026-05-26 00:42:59 +02:00
201 changed files with 20650 additions and 5829 deletions
Generated
+2
View File
@@ -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"]
}
+44 -4
View File
@@ -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
+31 -3
View File
@@ -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."""
+141
View File
@@ -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"
]
}
+1
View File
@@ -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(
+6
View File
@@ -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"]
}
+12
View File
@@ -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"
},
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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(),
}
)
+2 -2
View File
@@ -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"]
}
-11
View File
@@ -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(
+4 -1
View File
@@ -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"
},
+6
View File
@@ -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
+47 -4
View File
@@ -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"]
}
+2
View File
@@ -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,
+1
View File
@@ -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"
+1 -2
View File
@@ -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"
+1 -1
View File
@@ -1240,7 +1240,7 @@ class MqttDiscoveryUpdateMixin(Entity):
super().add_to_platform_abort()
async def async_will_remove_from_hass(self) -> None:
"""Stop listening to signal and cleanup discovery data.."""
"""Stop listening to signal and cleanup discovery data."""
self._cleanup_discovery_on_remove()
def _cleanup_discovery_on_remove(self) -> None:
@@ -378,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%]"
}
+5 -6
View File
@@ -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
+3 -3
View File
@@ -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"),
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
},
+16 -15
View File
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_HAS_PWD
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 -2
View File
@@ -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"
+1 -3
View File
@@ -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"]
}
+45
View File
@@ -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