Compare commits

..

91 Commits

Author SHA1 Message Date
J. Nick Koston
7f4063f91e Bump aiodns to 4.0.0 (#160707) 2026-01-11 07:31:31 -10:00
mattreim
080ba46885 Add model id RODRET wireless dimmer (#160636) 2026-01-11 18:22:19 +01:00
Brett Adams
2cb028ee79 Catch any migration failures in Teslemetry (#160549) 2026-01-11 16:46:30 +01:00
mettolen
72655dbf0b Pump pysaunum to 0.2.0 (#160668) 2026-01-11 16:14:45 +01:00
Erwin Douna
153278221d Bump pytado 0.18.16 (#160724) 2026-01-11 13:24:22 +01:00
Daniel Hjelseth Høyer
4942ce7e86 Better handling of ratelimiting from Tibber (#160599)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-11 11:40:27 +01:00
hanwg
98e918cd8a Improve polling error messages for Telegram bot (#160675) 2026-01-11 06:54:50 +01:00
J. Nick Koston
1efc87bfef Bump easyenergy to 2.2.0 (#160709) 2026-01-10 18:54:50 -10:00
Simon Delberghe
b4360ccbd9 Move condition to prioritize preset mode (eco/comfort...) instead of program name in Overkiz (#160189) 2026-01-10 23:58:19 +01:00
Ernst Klamer
ce234d69a7 Revert bthome-ble back to 3.16.0 to fix missing data (#160694) 2026-01-10 09:47:30 -10:00
Álvaro Fernández Rojas
b2a198e230 Update aioairzone to v1.0.5 (#160688) 2026-01-10 20:43:10 +01:00
Michael Hansen
538009d2df Bump pysilero-vad to 3.2.0 (#160691) 2026-01-10 13:35:46 -06:00
Clifford Roche
99329851a2 Bump greeclimate to 2.1.1 (#160683) 2026-01-10 19:51:04 +01:00
DeerMaximum
f8ec395e96 Use snapshots for binary sensor tests in Nina (#160532) 2026-01-10 17:47:29 +01:00
mettolen
98fe189edf Add recalibrate CO2 button to Airobot (#160679) 2026-01-10 17:37:14 +01:00
Samuel Xiao
7b413e3fd3 Bumb switchbot api to v2.10.0 (#160657) 2026-01-10 13:01:55 +01:00
Paul Tarjan
00ca5473d4 Bump pyhik to 0.4.0 (#160654) 2026-01-10 08:04:29 +01:00
Martin Hjelmare
33c808713e Fix Z-Wave creating notification binary sensor for idle state (#160604) 2026-01-10 02:43:13 +01:00
Sid
c97437fbf3 Add the professionel5e filter series to eheimdigital (#155550) 2026-01-09 21:24:01 +01:00
Jordan Harvey
ad8f14fec1 Bump pynintendoparental to 2.3.2 (#160626)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-09 20:09:31 +01:00
karwosts
7df586eff1 Use duration selector for timer service (#160391) 2026-01-09 20:07:32 +01:00
Manu
f6fa95d2f7 Rename Namecheap FreeDNS to Dynamic DNS (#160625) 2026-01-09 19:37:03 +01:00
Tero Paloheimo
23a8300012 Add Ruuvi IAQS to Ruuvi BLE (#160529) 2026-01-09 19:04:30 +01:00
Glenn de Haan
694d67d2d5 Add HDFury switch platform (#160620) 2026-01-09 18:08:37 +01:00
mettolen
a26c910db7 Add number entities to Saunum integration (#160444) 2026-01-09 18:04:49 +01:00
mettolen
ac9d04624b Update Airobot integration to gold quality tier (#160525) 2026-01-09 18:02:27 +01:00
James
a0ec7bde33 Introduce better types in Yardian coordinator (#152641)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-09 17:55:08 +01:00
Vasily G.
5f7dc49215 Spotify: user Liked Songs collection playable (#160452)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-09 17:48:39 +01:00
LG-ThinQ-Integration
f79eef150e Add humidifier entity for humidifier and dehumidifier to LG ThinQ (#152593)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2026-01-09 17:41:20 +01:00
Arie Catsman
1733599442 Change device class to energy_storage for some enphase_envoy battery entities (#160603) 2026-01-09 16:48:00 +01:00
Thomas55555
3bde4f606b Bump google-air-quality-api to 2.1.2 (#160561) 2026-01-09 16:40:38 +01:00
Christopher Fenner
afb635125c Bump PyViCare to 2.55.1 (#156875) 2026-01-09 16:39:31 +01:00
James
876d54ad4d Yardian: Add sensors (#153020)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-09 16:31:29 +01:00
Tom Matheussen
c20cd8fb94 Add missing segment speed icons for WLED (#160597) 2026-01-09 15:42:23 +01:00
Colin
e15b2ec0cb openevse: Add device_info and unique_id to sensors (#160543)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-09 15:02:07 +01:00
azerty9971
1829452ef1 Change Tuya covers to prefer set_position instead of instruction_wrapper (#160526) 2026-01-09 14:31:31 +01:00
Dan Čermák
9d8dc9ec06 Fix JSON serialization of time objects in anthropic tool results (#160459)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-01-09 12:06:36 +01:00
Bram Kragten
72a3523193 Fix trigger selectors (#160519) 2026-01-09 11:43:33 +01:00
Maciej Bieniek
7c3541e983 Fix AttributeError for missing/incomplete health data in Tractive (#160553) 2026-01-09 10:55:33 +01:00
Michael
8246fc78fa Fix for older Fritzbox models which do not support smarthome triggers (#160555) 2026-01-09 10:52:44 +01:00
tronikos
78dd3aee10 Bump opower to 0.16.1 (#160588) 2026-01-09 10:51:39 +01:00
Brett Adams
c22e578aca Fix config flow bug in Tesla Fleet (#160591) 2026-01-09 10:41:33 +01:00
Brett Adams
1021c1959e Fix Climate signal in Teslemetry (#160571) 2026-01-09 10:41:18 +01:00
Brett Adams
d3161d8e92 Fix translation of unknown response in Teslemetry & Tesla Fleet (#160506) 2026-01-09 10:16:00 +01:00
Johann Kellerman
fc468b56c8 Bump pysma to 1.1.0 (#160583) 2026-01-09 10:14:15 +01:00
Markus Jacobsen
ea48dc3c58 Add battery charging binary sensor to Bang & Olufsen (#160527)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-09 09:59:20 +01:00
cdnninja
11dde08d79 Correct vesync missing return type (#160580) 2026-01-09 08:09:31 +01:00
epenet
5e43708a40 Skip Tuya update if it is not relevent (#160407) 2026-01-09 07:01:43 +01:00
osohotwateriot
1ac2280266 Change nettleie to grid fee in english strings (#160516) 2026-01-08 23:11:42 +00:00
puddly
6b1ad8d2d1 Bump serialx to v0.6.2 (#160545) 2026-01-08 23:10:29 +00:00
Michael Hansen
c1741237f4 Bump pysilero-vad to 3.1.0 (#160554) 2026-01-08 23:09:18 +00:00
LG-ThinQ-Integration
8ecacd6490 Add target_humidity_step attribute to humidifier (#156906)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2026-01-08 23:06:31 +00:00
Glenn de Haan
188ab3930c Add HDFury button platform (#160548) 2026-01-08 22:14:23 +01:00
Michael Hansen
a8dba53185 Revert "Update voluptuous and voluptuous-openapi" (#160530) 2026-01-08 10:25:46 -06:00
Erwin Douna
a2ef0c9a75 Portainer add prune unused images (#160137)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 17:05:45 +01:00
Jan Bouwhuis
5a1fe17580 Bump Intergas Incomfort-client to v0.6.11 (#160520) 2026-01-08 16:44:21 +01:00
ElCruncharino
34388f52a6 Add asyncio-level timeout to Backblaze B2 uploads (#160468) 2026-01-08 16:39:47 +01:00
DeerMaximum
fc2199fcf7 Add bronze quality scale for NINA (#155191) 2026-01-08 15:53:43 +01:00
DeerMaximum
2236f8cd07 Fix typo in NINA config flow (#160523) 2026-01-08 15:44:50 +01:00
Klaas Schoute
8d376027bf Add support for gas meter in Powerfox integration (#158196)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-08 14:53:00 +01:00
JHSL
47e91bc2ec Add dishwasher program Dishcare.Dishwasher.Program.IntensiveFixedZone (#160463) 2026-01-08 14:45:44 +01:00
Zoltán Farkasdi
33d1cdd0ac Refactor netatmo binary sensors (#160352) 2026-01-08 13:24:05 +01:00
Brett Adams
f46de054ba Add missing data_description translations to Tessie (#160511)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:02:36 +01:00
Brett Adams
741aa714dd Add missing PARALLEL_UPDATES to Tesla Fleet (#160510) 2026-01-08 12:40:38 +01:00
osohotwateriot
5fac7d4ffb Add Nettleie optimization option (#160494) 2026-01-08 12:24:00 +01:00
Glenn de Haan
341c441e61 Add HDFury integration (#159996) 2026-01-08 12:21:04 +01:00
wollew
a1edf0a77c fix rain sensor for some rare velux windows (#160504) 2026-01-08 12:19:40 +01:00
Erik Montnemery
dd84b52c7b Bump python-otbr-api to 2.7.1 (#160496) 2026-01-08 12:10:39 +01:00
Etienne C.
43ced677e5 Get the polling state of a sensor from a template (#159900) 2026-01-08 12:03:45 +01:00
Ville Skyttä
7a696935ed Add icons for Nord Pool highest and lowest price sensors (#159729) 2026-01-08 11:27:17 +01:00
Deyan Petrov
be3be360a7 Make Tuya binary sensor consider only updated properties (#160404)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-08 09:47:27 +01:00
Mick Vleeshouwer
092ebaaeb1 Bump pyOverkiz to 1.19.4 (#160457) 2026-01-08 08:41:30 +01:00
Retha Runolfsson
e8025317ed Bump PySwitchbot to 0.76.0 (#160470) 2026-01-08 08:39:23 +01:00
wollew
39b025dfea catch and wrap exceptions when doing pyvlx actions in velux entities (#160430)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-08 00:06:26 +01:00
DeerMaximum
1b436a8808 Use async_configure in NINA to set flow data in tests (#160435)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-07 23:48:42 +01:00
Markus Jacobsen
a7440e3756 Add battery support to Bang & Olufsen (#159994) 2026-01-07 23:40:22 +01:00
wollew
2c7852f94b remove workaround for recognition of closed velux windows (#160433) 2026-01-07 23:39:37 +01:00
Maikel Punie
bd4653f830 Update velbus quality scale rules for docs (#160200) 2026-01-07 23:32:45 +01:00
Tero Paloheimo
c0b2847a87 Update ruuvitag-ble to 0.4.0 (#160441) 2026-01-07 23:32:03 +01:00
J. Diego Rodríguez Royo
8853f6698b Add steam mode and hot air gentle programs to Home Connect (#160445) 2026-01-07 23:10:20 +01:00
Artem Draft
b1a3ad6ac3 Improve Bravia TV logging messages (#160394) 2026-01-07 23:09:46 +01:00
Arie Catsman
dafa2e69e2 Optimize enphase_envoy code for on_phase use (#160448) 2026-01-07 23:09:00 +01:00
Chris
2c6d6f8ab4 Add unique_id to openevse user flow and import flow (#160436)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 23:06:25 +01:00
J. Diego Rodríguez Royo
10d32b7f23 Bump aiohomeconnect to version 0.28.0 (#160438) 2026-01-07 20:44:36 +01:00
TheJulianJES
e4dc4e0ced Bump ZHA to 0.0.84 (#160440) 2026-01-07 19:57:09 +01:00
Maikel Punie
6f9794f235 Add icon translations for velbus (#160439) 2026-01-07 19:26:47 +01:00
Paul Bottein
b8cff13737 Fix hvac_mode validation in climate.hvac_mode_changed trigger (#160364) 2026-01-07 17:44:03 +01:00
Bram Kragten
7777714cc0 Update frontend to 20260107.0 (#160434) 2026-01-07 17:34:23 +01:00
Chris
f15d5cdf2a Add zeroconf discovery to openevse (#160318)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-07 16:42:32 +01:00
DeerMaximum
6181f4e7de NINA Use MockConfigEntry to setup integration in test (#160324) 2026-01-07 16:33:06 +01:00
Robert Resch
80df3b5b80 Bump deebot-client to 17.0.1 (#160428) 2026-01-07 16:07:11 +01:00
276 changed files with 10132 additions and 2983 deletions

6
CODEOWNERS generated
View File

@@ -661,6 +661,8 @@ build.json @home-assistant/supervisor
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
/tests/components/hassio/ @home-assistant/supervisor
/homeassistant/components/hdfury/ @glenndehaan
/tests/components/hdfury/ @glenndehaan
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
@@ -1170,8 +1172,8 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w
/tests/components/openevse/ @c00w
/homeassistant/components/openevse/ @c00w @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen

View File

@@ -43,6 +43,13 @@ BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
AirobotButtonEntityDescription(
key="recalibrate_co2",
translation_key="recalibrate_co2",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
),
)

View File

@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"recalibrate_co2": {
"default": "mdi:molecule-co2"
}
},
"number": {
"hysteresis_band": {
"default": "mdi:delta"

View File

@@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "silver",
"quality_scale": "gold",
"requirements": ["pyairobotrest==0.2.0"]
}

View File

@@ -43,7 +43,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -59,6 +59,11 @@
}
},
"entity": {
"button": {
"recalibrate_co2": {
"name": "Recalibrate CO2 sensor"
}
},
"number": {
"hysteresis_band": {
"name": "Hysteresis band"

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.4"]
"requirements": ["aioairzone==1.0.5"]
}

View File

@@ -69,6 +69,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from homeassistant.util import slugify
from . import AnthropicConfigEntry
@@ -193,7 +194,7 @@ def _convert_content(
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json.dumps(content.tool_result),
content=json_dumps(content.tool_result),
)
external_tool = False
if not messages or messages[-1]["role"] != (

View File

@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"]
"requirements": ["pysilero-vad==3.2.0", "pyspeex-noise==1.0.2"]
}

View File

@@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__)
# Cache TTL for backup list (in seconds)
CACHE_TTL = 300
# Timeout for upload operations (in seconds)
# This prevents uploads from hanging indefinitely
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata files."""
@@ -329,13 +333,28 @@ class BackblazeBackupAgent(BackupAgent):
_LOGGER.debug("Uploading backup file %s with streaming", filename)
try:
content_type, _ = mimetypes.guess_type(filename)
file_version = await self._hass.async_add_executor_job(
self._upload_unbound_stream_sync,
reader,
filename,
content_type or "application/x-tar",
file_info,
file_version = await asyncio.wait_for(
self._hass.async_add_executor_job(
self._upload_unbound_stream_sync,
reader,
filename,
content_type or "application/x-tar",
file_info,
),
timeout=UPLOAD_TIMEOUT,
)
except TimeoutError:
_LOGGER.error(
"Upload of %s timed out after %s seconds", filename, UPLOAD_TIMEOUT
)
reader.abort()
raise BackupAgentError(
f"Upload timed out after {UPLOAD_TIMEOUT} seconds"
) from None
except asyncio.CancelledError:
_LOGGER.warning("Upload of %s was cancelled", filename)
reader.abort()
raise
finally:
reader.close()

View File

@@ -34,7 +34,12 @@ class BeoData:
type BeoConfigEntry = ConfigEntry[BeoData]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:

View File

@@ -0,0 +1,63 @@
"""Binary Sensor entities for the Bang & Olufsen integration."""
from __future__ import annotations
from mozart_api.models import BatteryState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
from .entity import BeoEntity
from .util import supports_battery
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BeoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Binary Sensor entities from config entry."""
if await supports_battery(config_entry.runtime_data.client):
async_add_entities(new_entities=[BeoBinarySensorBatteryCharging(config_entry)])
class BeoBinarySensorBatteryCharging(BinarySensorEntity, BeoEntity):
"""Battery charging Binary Sensor."""
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
_attr_is_on = False
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Init the battery charging Binary Sensor."""
super().__init__(config_entry, config_entry.runtime_data.client)
self._attr_unique_id = f"{self._unique_id}_charging"
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
self._update_battery_charging,
)
)
async def _update_battery_charging(self, data: BatteryState) -> None:
"""Update battery charging."""
self._attr_is_on = bool(data.is_charging)
self.async_write_ha_state()

View File

@@ -115,6 +115,7 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
BATTERY = "battery"
BEO_REMOTE_BUTTON = "beo_remote_button"
BUTTON = "button"
PLAYBACK_ERROR = "playback_error"

View File

@@ -4,8 +4,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -55,6 +57,19 @@ async def async_get_config_entry_diagnostics(
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get Battery Sensor states
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_battery_level"] = state_dict
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
@@ -72,4 +87,26 @@ async def async_get_config_entry_diagnostics(
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
# Get Mozart battery entity
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["battery_level"] = state_dict
# Get Mozart battery charging entity
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_charging"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["charging"] = state_dict
return data

View File

@@ -0,0 +1,139 @@
"""Sensor entities for the Bang & Olufsen integration."""
from __future__ import annotations
import contextlib
from datetime import timedelta
from aiohttp import ClientConnectorError
from mozart_api.exceptions import ApiException
from mozart_api.models import BatteryState, PairedRemote
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
from .entity import BeoEntity
from .util import get_remotes, supports_battery
SCAN_INTERVAL = timedelta(minutes=15)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BeoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
entities: list[BeoSensor] = []
# Check for Mozart device with battery
if await supports_battery(config_entry.runtime_data.client):
entities.append(BeoSensorBatteryLevel(config_entry))
# Add any Beoremote One remotes
entities.extend(
[
BeoSensorRemoteBatteryLevel(config_entry, remote)
for remote in (await get_remotes(config_entry.runtime_data.client))
]
)
async_add_entities(entities, update_before_add=True)
class BeoSensor(SensorEntity, BeoEntity):
"""Base Bang & Olufsen Sensor."""
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Initialize Sensor."""
super().__init__(config_entry, config_entry.runtime_data.client)
class BeoSensorBatteryLevel(BeoSensor):
"""Battery level Sensor for Mozart devices."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
self._attr_unique_id = f"{self._unique_id}_battery_level"
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
self._update_battery,
)
)
async def _update_battery(self, data: BatteryState) -> None:
"""Update sensor value."""
self._attr_native_value = data.battery_level
self.async_write_ha_state()
class BeoSensorRemoteBatteryLevel(BeoSensor):
"""Battery level Sensor for the Beoremote One."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_should_poll = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
# Serial number is not None, as the remote object is provided by get_remotes
assert remote.serial_number
self._attr_unique_id = (
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
)
self._attr_native_value = remote.battery_level
self._remote = remote
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
async def async_update(self) -> None:
"""Poll battery status."""
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
for remote in await get_remotes(self._client):
if remote.serial_number == self._remote.serial_number:
self._attr_native_value = remote.battery_level
break

View File

@@ -84,3 +84,10 @@ def get_remote_keys() -> list[str]:
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]
async def supports_battery(client: MozartClient) -> bool:
"""Get if a Mozart device has a battery."""
battery_state = await client.get_battery_state()
return battery_state.state != "BatteryNotPresent"

View File

@@ -6,6 +6,7 @@ import logging
from typing import TYPE_CHECKING
from mozart_api.models import (
BatteryState,
BeoRemoteButton,
ButtonEvent,
ListeningModeProps,
@@ -60,6 +61,7 @@ class BeoWebsocket(BeoBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
self._client.get_battery_notifications(self.on_battery_notification)
self._client.get_beo_remote_button_notifications(
self.on_beo_remote_button_notification
)
@@ -115,6 +117,14 @@ class BeoWebsocket(BeoBase):
notification,
)
def on_battery_notification(self, notification: BatteryState) -> None:
"""Send battery dispatch."""
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
notification,
)
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
"""Send beo_remote_button dispatch."""
if TYPE_CHECKING:

View File

@@ -22,7 +22,7 @@ from homeassistant.components.media_player import MediaType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -56,8 +56,31 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
"""Catch Bravia errors and log message."""
try:
await func(self, *args, **kwargs)
except BraviaNotFound as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_offline",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except BraviaError as err:
_LOGGER.error("Command error: %s", err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
await self.async_request_refresh()
return wrapper
@@ -165,17 +188,35 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
if self.skipped_updates < 10:
self.connected = False
self.skipped_updates += 1
_LOGGER.debug("Update skipped, Bravia API service is reloading")
_LOGGER.debug(
"Update for %s skipped: the Bravia API service is reloading",
self.config_entry.title,
)
return
raise UpdateFailed("Error communicating with device") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
self.is_on = False
self.connected = False
_LOGGER.debug("Update skipped, Bravia TV is off")
_LOGGER.debug(
"Update for %s skipped: the TV is turned off", self.config_entry.title
)
except BraviaError as err:
self.is_on = False
self.connected = False
raise UpdateFailed("Error communicating with device") from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
async def async_update_volume(self) -> None:
"""Update volume information."""

View File

@@ -55,5 +55,22 @@
"name": "Terminate apps"
}
}
},
"exceptions": {
"command_error": {
"message": "Error sending command to {device}: {error}"
},
"command_error_not_found": {
"message": "Error sending command to {device}: the Bravia API service is reloading"
},
"command_error_offline": {
"message": "Error sending command to {device}: the TV is turned off"
},
"update_error": {
"message": "Error updating data for {device}: {error}"
},
"update_error_not_found": {
"message": "Error updating data for {device}: the Bravia API service is stuck"
}
}
}

View File

@@ -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.16.0"]
}

View File

@@ -33,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [HVACMode]
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
),
},
}

View File

@@ -19,6 +19,10 @@
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
@@ -27,14 +31,11 @@
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:

View File

@@ -169,6 +169,7 @@ FRIENDS_OF_HUE_SWITCH = {
}
RODRET_REMOTE_MODEL = "RODRET Dimmer"
RODRET_REMOTE_MODEL_2 = "RODRET wireless dimmer"
RODRET_REMOTE = {
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001},
@@ -624,6 +625,7 @@ REMOTES = {
HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE,
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
RODRET_REMOTE_MODEL: RODRET_REMOTE,
RODRET_REMOTE_MODEL_2: RODRET_REMOTE,
SOMRIG_REMOTE_MODEL: SOMRIG_REMOTE,
STYRBAR_REMOTE_MODEL: STYRBAR_REMOTE,
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,

View File

@@ -28,10 +28,11 @@ async def async_setup_entry(
DemoHumidifier(
name="Humidifier",
mode=None,
target_humidity=68,
target_humidity=65,
current_humidity=45,
action=HumidifierAction.HUMIDIFYING,
device_class=HumidifierDeviceClass.HUMIDIFIER,
target_humidity_step=5,
),
DemoHumidifier(
name="Dehumidifier",
@@ -66,6 +67,7 @@ class DemoHumidifier(HumidifierEntity):
is_on: bool = True,
action: HumidifierAction | None = None,
device_class: HumidifierDeviceClass | None = None,
target_humidity_step: float | None = None,
) -> None:
"""Initialize the humidifier device."""
self._attr_name = name
@@ -79,6 +81,7 @@ class DemoHumidifier(HumidifierEntity):
self._attr_mode = mode
self._attr_available_modes = available_modes
self._attr_device_class = device_class
self._attr_target_humidity_step = target_humidity_step
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==3.6.1"]
"requirements": ["aiodns==4.0.0"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==2.1.2"],
"requirements": ["easyenergy==2.2.0"],
"single_config_entry": true
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
}

View File

@@ -37,7 +37,7 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="EHEIM",
model=device.device_type.model_name,
model=device.model_name,
identifiers={(DOMAIN, device.mac_address)},
suggested_area=device.aquarium_name,
sw_version=device.sw_version,
@@ -59,9 +59,9 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate AirGradient calls to handle exceptions.
"""Decorate eheimdigital calls to handle exceptions.
A decorator that wraps the passed in function, catches AirGradient errors.
A decorator that wraps the passed in function, catches eheimdigital errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:

View File

@@ -6,6 +6,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import HeaterUnit
@@ -21,6 +22,7 @@ from homeassistant.const import (
PRECISION_WHOLE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -42,6 +44,34 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
uom_fn: Callable[[_DeviceT], str] | None = None
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
EheimDigitalNumberDescription[EheimDigitalFilter](
key="high_pulse_time",
translation_key="high_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.high_pulse_time,
set_value_fn=lambda device, value: device.set_high_pulse_time(int(value)),
),
EheimDigitalNumberDescription[EheimDigitalFilter](
key="low_pulse_time",
translation_key="low_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.low_pulse_time,
set_value_fn=lambda device, value: device.set_low_pulse_time(int(value)),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalNumberDescription[EheimDigitalClassicVario], ...
] = (
@@ -145,6 +175,13 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalNumber[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalHeater):
entities.extend(
EheimDigitalNumber[EheimDigitalHeater](

View File

@@ -2,13 +2,19 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, override
from typing import Any, Literal, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import FilterMode
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import (
FilterMode,
FilterModeProf,
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfFrequency, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,8 +30,109 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
):
"""Class describing EHEIM Digital select entities."""
options_fn: Callable[[_DeviceT], list[str]] | None = None
use_api_unit: Literal[True] | None = None
value_fn: Callable[[_DeviceT], str | None]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
EheimDigitalSelectDescription[EheimDigitalFilter](
key="filter_mode",
translation_key="filter_mode",
entity_category=EntityCategory.CONFIG,
options=[item.lower() for item in FilterModeProf._member_names_],
value_fn=lambda device: device.filter_mode.name.lower(),
set_value_fn=lambda device, value: device.set_filter_mode(
FilterModeProf[value.upper()]
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="manual_speed",
translation_key="manual_speed",
entity_category=EntityCategory.CONFIG,
unit_of_measurement=UnitOfFrequency.HERTZ,
options_fn=lambda device: [str(i) for i in device.filter_manual_values],
value_fn=lambda device: str(device.manual_speed),
set_value_fn=lambda device, value: device.set_manual_speed(float(value)),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="const_flow_speed",
translation_key="const_flow_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.const_flow]),
set_value_fn=(
lambda device, value: device.set_const_flow(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="day_speed",
translation_key="day_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.day_speed]),
set_value_fn=(
lambda device, value: device.set_day_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="night_speed",
translation_key="night_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.night_speed]
),
set_value_fn=(
lambda device, value: device.set_night_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="high_pulse_speed",
translation_key="high_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.high_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_high_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="low_pulse_speed",
translation_key="low_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.low_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_low_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -34,11 +141,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSelectDescription[EheimDigitalClassicVario](
key="filter_mode",
translation_key="filter_mode",
value_fn=(
lambda device: device.filter_mode.name.lower()
if device.filter_mode is not None
else None
),
value_fn=lambda device: device.filter_mode.name.lower(),
set_value_fn=(
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
),
@@ -68,6 +171,11 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalFilterSelect(coordinator, device, description)
for description in FILTER_DESCRIPTIONS
)
async_add_entities(entities)
@@ -82,6 +190,8 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
entity_description: EheimDigitalSelectDescription[_DeviceT]
_attr_options: list[str]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
@@ -91,13 +201,49 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
"""Initialize an EHEIM Digital select entity."""
super().__init__(coordinator, device)
self.entity_description = description
if description.options_fn is not None:
self._attr_options = description.options_fn(device)
elif description.options is not None:
self._attr_options = description.options
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
@exception_handler
async def async_select_option(self, option: str) -> None:
return await self.entity_description.set_value_fn(self._device, option)
if await_return := self.entity_description.set_value_fn(self._device, option):
return await await_return
return None
@override
def _async_update_attrs(self) -> None:
self._attr_current_option = self.entity_description.value_fn(self._device)
class EheimDigitalFilterSelect(EheimDigitalSelect[EheimDigitalFilter]):
"""Represent an EHEIM Digital Filter select entity."""
entity_description: EheimDigitalSelectDescription[EheimDigitalFilter]
_attr_native_unit_of_measurement: str | None
@override
def _async_update_attrs(self) -> None:
if (
self.entity_description.options is None
and self.entity_description.options_fn is not None
):
self._attr_options = self.entity_description.options_fn(self._device)
if self.entity_description.use_api_unit:
if (
self.entity_description.unit_of_measurement
== UnitOfVolumeFlowRate.LITERS_PER_HOUR
and self._device.usrdta["unit"]
== int(EheimDigitalUnitOfMeasurement.US_CUSTOMARY)
):
self._attr_native_unit_of_measurement = (
UnitOfVolumeFlowRate.GALLONS_PER_HOUR
)
else:
self._attr_native_unit_of_measurement = (
self.entity_description.unit_of_measurement
)
super()._async_update_attrs()

View File

@@ -6,6 +6,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import (
@@ -13,7 +14,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfFrequency, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -33,6 +34,27 @@ class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
value_fn: Callable[[_DeviceT], float | str | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSensorDescription[EheimDigitalFilter], ...] = (
EheimDigitalSensorDescription[EheimDigitalFilter](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
EheimDigitalSensorDescription[EheimDigitalFilter](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
@@ -54,11 +76,7 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
value_fn=lambda device: device.error_code.name.lower(),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
@@ -80,6 +98,13 @@ async def async_setup_entry(
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities += [
EheimDigitalSensor[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
]
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](

View File

@@ -61,6 +61,12 @@
"day_speed": {
"name": "Day speed"
},
"high_pulse_time": {
"name": "High pulse duration"
},
"low_pulse_time": {
"name": "Low pulse duration"
},
"manual_speed": {
"name": "Manual speed"
},
@@ -78,13 +84,32 @@
}
},
"select": {
"const_flow_speed": {
"name": "Constant flow speed"
},
"day_speed": {
"name": "Day speed"
},
"filter_mode": {
"name": "Filter mode",
"state": {
"bio": "Bio",
"constant_flow": "Constant flow",
"manual": "Manual",
"pulse": "Pulse"
}
},
"high_pulse_speed": {
"name": "High pulse speed"
},
"low_pulse_speed": {
"name": "Low pulse speed"
},
"manual_speed": {
"name": "Manual speed"
},
"night_speed": {
"name": "Night speed"
}
},
"sensor": {
@@ -99,8 +124,17 @@
"rotor_stuck": "Rotor stuck"
}
},
"operating_time": {
"name": "Operating time"
},
"service_hours": {
"name": "Remaining hours until service"
},
"turn_feeding_time": {
"name": "Remaining off time after feeding"
},
"turn_off_time": {
"name": "Remaining off time"
}
},
"time": {

View File

@@ -4,6 +4,7 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
@@ -30,8 +31,8 @@ async def async_setup_entry(
"""Set up the switch entities for one or multiple devices."""
entities: list[SwitchEntity] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
async_add_entities(entities)
@@ -39,10 +40,10 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalClassicVarioSwitch(
EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity
class EheimDigitalFilterSwitch(
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
):
"""Represent an EHEIM Digital classicVARIO switch entity."""
"""Represent an EHEIM Digital classicVARIO or filter switch entity."""
_attr_translation_key = "filter_active"
_attr_name = None
@@ -50,9 +51,9 @@ class EheimDigitalClassicVarioSwitch(
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: EheimDigitalClassicVario,
device: EheimDigitalClassicVario | EheimDigitalFilter,
) -> None:
"""Initialize an EHEIM Digital classicVARIO switch entity."""
"""Initialize an EHEIM Digital classicVARIO or filter switch entity."""
super().__init__(coordinator, device)
self._attr_unique_id = device.mac_address
self._async_update_attrs()

View File

@@ -7,6 +7,7 @@ from typing import Any, final, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from homeassistant.components.time import TimeEntity, TimeEntityDescription
@@ -28,6 +29,23 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
EheimDigitalTimeDescription[EheimDigitalFilter](
key="day_start_time",
translation_key="day_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.day_start_time,
set_value_fn=lambda device, value: device.set_day_start_time(value),
),
EheimDigitalTimeDescription[EheimDigitalFilter](
key="night_start_time",
translation_key="night_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.night_start_time,
set_value_fn=lambda device, value: device.set_night_start_time(value),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalTimeDescription[EheimDigitalClassicVario], ...
] = (
@@ -79,6 +97,13 @@ async def async_setup_entry(
"""Set up the time entities for one or multiple devices."""
entities: list[EheimDigitalTime[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalTime[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
EheimDigitalTime[EheimDigitalClassicVario](

View File

@@ -59,38 +59,13 @@ class FlowToGridSourceType(TypedDict):
number_energy_price: float | None # Price for energy ($/kWh)
class PowerConfig(TypedDict, total=False):
"""Dictionary holding power sensor configuration options.
Users can configure power sensors in three ways:
1. Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
2. Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
3. Two sensors: separate positive sensors for each direction
"""
# Standard: single sensor (positive=discharge/from_grid, negative=charge/to_grid)
stat_rate: str
# Inverted: single sensor with opposite polarity (needs to be multiplied by -1)
stat_rate_inverted: str
# Two sensors: separate positive sensors for each direction
# Result = stat_rate_from - stat_rate_to (positive when net outflow)
stat_rate_from: str # Battery: discharge, Grid: consumption
stat_rate_to: str # Battery: charge, Grid: return
class GridPowerSourceType(TypedDict, total=False):
class GridPowerSourceType(TypedDict):
"""Dictionary holding the source of grid power consumption."""
# statistic_id of a power meter (kW)
# negative values indicate grid return
# This is either the original sensor or a generated template sensor
stat_rate: str
# User's original power sensor configuration
power_config: PowerConfig
class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""
@@ -122,12 +97,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
# This is either the original sensor or a generated template sensor
stat_rate: NotRequired[str]
# User's original power sensor configuration
power_config: NotRequired[PowerConfig]
class GasSourceType(TypedDict):
"""Dictionary holding the source of gas consumption."""
@@ -240,47 +211,10 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
}
)
def _validate_power_config(val: dict[str, Any]) -> dict[str, Any]:
"""Validate power_config has at least one option."""
if not val:
raise vol.Invalid("power_config must have at least one option")
return val
POWER_CONFIG_SCHEMA = vol.All(
vol.Schema(
{
vol.Exclusive("stat_rate", "power_source"): str,
vol.Exclusive("stat_rate_inverted", "power_source"): str,
# stat_rate_from/stat_rate_to: two sensors for bidirectional power
# Battery: from=discharge (out), to=charge (in)
# Grid: from=consumption, to=return
vol.Inclusive("stat_rate_from", "two_sensors"): str,
vol.Inclusive("stat_rate_to", "two_sensors"): str,
}
),
_validate_power_config,
)
def _validate_grid_power_source(val: dict[str, Any]) -> dict[str, Any]:
"""Validate grid power source has either stat_rate or power_config."""
if "stat_rate" not in val and "power_config" not in val:
raise vol.Invalid("Either stat_rate or power_config is required")
return val
GRID_POWER_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
# stat_rate and power_config are both optional schema keys, but the validator
# requires that at least one is provided; power_config takes precedence
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
}
),
_validate_grid_power_source,
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)
@@ -291,7 +225,7 @@ def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[di
val: list[dict],
) -> list[dict]:
"""Ensure that the user doesn't add duplicate values."""
counts = Counter(item.get(key) for item in val if item.get(key) is not None)
counts = Counter(flow_from[key] for flow_from in val)
for value, count in counts.items():
if count > 1:
@@ -333,10 +267,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
# Both stat_rate and power_config are optional
# If power_config is provided, it takes precedence and stat_rate is overwritten
vol.Optional("stat_rate"): str,
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -456,12 +387,6 @@ class EnergyManager:
if key in update:
data[key] = update[key]
# Process energy sources and set stat_rate for power configs
if "energy_sources" in update:
data["energy_sources"] = self._process_energy_sources(
data["energy_sources"]
)
self.data = data
self._store.async_delay_save(lambda: data, 60)
@@ -470,74 +395,6 @@ class EnergyManager:
await asyncio.gather(*(listener() for listener in self._update_listeners))
def _process_energy_sources(self, sources: list[SourceType]) -> list[SourceType]:
"""Process energy sources and set stat_rate for power configs."""
from .helpers import generate_power_sensor_entity_id # noqa: PLC0415
processed: list[SourceType] = []
for source in sources:
if source["type"] == "battery":
source = self._process_battery_power(
source, generate_power_sensor_entity_id
)
elif source["type"] == "grid":
source = self._process_grid_power(
source, generate_power_sensor_entity_id
)
processed.append(source)
return processed
def _process_battery_power(
self,
source: BatterySourceType,
generate_entity_id: Callable[[str, PowerConfig], str],
) -> BatterySourceType:
"""Set stat_rate for battery if power_config is specified."""
if "power_config" not in source:
return source
config = source["power_config"]
# If power_config has stat_rate (standard), just use it directly
if "stat_rate" in config:
return {**source, "stat_rate": config["stat_rate"]}
# For inverted or two-sensor config, set stat_rate to the generated entity_id
entity_id = generate_entity_id("battery", config)
if entity_id:
return {**source, "stat_rate": entity_id}
return source
def _process_grid_power(
self,
source: GridSourceType,
generate_entity_id: Callable[[str, PowerConfig], str],
) -> GridSourceType:
"""Set stat_rate for grid power sources if power_config is specified."""
if "power" not in source:
return source
processed_power: list[GridPowerSourceType] = []
for power in source["power"]:
if "power_config" in power:
config = power["power_config"]
# If power_config has stat_rate (standard), just use it directly
if "stat_rate" in config:
processed_power.append({**power, "stat_rate": config["stat_rate"]})
continue
# For inverted or two-sensor config, set stat_rate to generated entity_id
entity_id = generate_entity_id("grid", config)
if entity_id:
processed_power.append({**power, "stat_rate": entity_id})
continue
processed_power.append(power)
return {**source, "power": processed_power}
@callback
def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None:
"""Listen for data updates."""

View File

@@ -1,35 +0,0 @@
"""Helpers for the Energy integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .data import PowerConfig
def generate_power_sensor_unique_id(source_type: str, config: PowerConfig) -> str:
"""Generate a unique ID for a power transform sensor."""
if "stat_rate_inverted" in config:
sensor_id = config["stat_rate_inverted"].replace(".", "_")
return f"energy_power_{source_type}_inv_{sensor_id}"
if "stat_rate_from" in config and "stat_rate_to" in config:
from_id = config["stat_rate_from"].replace(".", "_")
to_id = config["stat_rate_to"].replace(".", "_")
return f"energy_power_{source_type}_combined_{from_id}_{to_id}"
return ""
def generate_power_sensor_entity_id(source_type: str, config: PowerConfig) -> str:
"""Generate an entity ID for a power transform sensor."""
if "stat_rate_inverted" in config:
# Use source sensor name with _inverted suffix
source = config["stat_rate_inverted"]
if source.startswith("sensor."):
return f"{source}_inverted"
return f"sensor.{source.replace('.', '_')}_inverted"
if "stat_rate_from" in config and "stat_rate_to" in config:
# Include sensor IDs to avoid collisions when multiple combined configs exist
from_sensor = config["stat_rate_from"].removeprefix("sensor.")
return f"sensor.energy_{source_type}_{from_sensor}_power"
return ""

View File

@@ -19,12 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
reset_detected,
)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
from homeassistant.core import (
HomeAssistant,
State,
@@ -41,8 +36,7 @@ from homeassistant.util import dt as dt_util, unit_conversion
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import DOMAIN
from .data import EnergyManager, PowerConfig, async_get_manager
from .helpers import generate_power_sensor_entity_id, generate_power_sensor_unique_id
from .data import EnergyManager, async_get_manager
SUPPORTED_STATE_CLASSES = {
SensorStateClass.MEASUREMENT,
@@ -143,7 +137,6 @@ class SensorManager:
self.manager = manager
self.async_add_entities = async_add_entities
self.current_entities: dict[tuple[str, str | None, str], EnergyCostSensor] = {}
self.current_power_entities: dict[str, EnergyPowerSensor] = {}
async def async_start(self) -> None:
"""Start."""
@@ -154,9 +147,8 @@ class SensorManager:
async def _process_manager_data(self) -> None:
"""Process manager data."""
to_add: list[EnergyCostSensor | EnergyPowerSensor] = []
to_add: list[EnergyCostSensor] = []
to_remove = dict(self.current_entities)
power_to_remove = dict(self.current_power_entities)
async def finish() -> None:
if to_add:
@@ -167,10 +159,6 @@ class SensorManager:
self.current_entities.pop(key)
await entity.async_remove()
for power_key, power_entity in power_to_remove.items():
self.current_power_entities.pop(power_key)
await power_entity.async_remove()
if not self.manager.data:
await finish()
return
@@ -197,13 +185,6 @@ class SensorManager:
to_remove,
)
# Process power sensors for battery and grid sources
self._process_power_sensor_data(
energy_source,
to_add,
power_to_remove,
)
await finish()
@callback
@@ -211,7 +192,7 @@ class SensorManager:
self,
adapter: SourceAdapter,
config: Mapping[str, Any],
to_add: list[EnergyCostSensor | EnergyPowerSensor],
to_add: list[EnergyCostSensor],
to_remove: dict[tuple[str, str | None, str], EnergyCostSensor],
) -> None:
"""Process sensor data."""
@@ -239,74 +220,6 @@ class SensorManager:
)
to_add.append(self.current_entities[key])
@callback
def _process_power_sensor_data(
self,
energy_source: Mapping[str, Any],
to_add: list[EnergyCostSensor | EnergyPowerSensor],
to_remove: dict[str, EnergyPowerSensor],
) -> None:
"""Process power sensor data for battery and grid sources."""
source_type = energy_source.get("type")
if source_type == "battery":
power_config = energy_source.get("power_config")
if power_config and self._needs_power_sensor(power_config):
self._create_or_keep_power_sensor(
source_type, power_config, to_add, to_remove
)
elif source_type == "grid":
for power in energy_source.get("power", []):
power_config = power.get("power_config")
if power_config and self._needs_power_sensor(power_config):
self._create_or_keep_power_sensor(
source_type, power_config, to_add, to_remove
)
@staticmethod
def _needs_power_sensor(power_config: PowerConfig) -> bool:
"""Check if power_config needs a transform sensor."""
# Only create sensors for inverted or two-sensor configs
# Standard stat_rate configs don't need a transform sensor
return "stat_rate_inverted" in power_config or (
"stat_rate_from" in power_config and "stat_rate_to" in power_config
)
def _create_or_keep_power_sensor(
self,
source_type: str,
power_config: PowerConfig,
to_add: list[EnergyCostSensor | EnergyPowerSensor],
to_remove: dict[str, EnergyPowerSensor],
) -> None:
"""Create a power sensor or keep an existing one."""
unique_id = generate_power_sensor_unique_id(source_type, power_config)
if not unique_id:
return
# If entity already exists, keep it
if unique_id in to_remove:
to_remove.pop(unique_id)
return
# If we already have this entity, skip
if unique_id in self.current_power_entities:
return
entity_id = generate_power_sensor_entity_id(source_type, power_config)
if not entity_id:
return
sensor = EnergyPowerSensor(
source_type,
power_config,
unique_id,
entity_id,
)
self.current_power_entities[unique_id] = sensor
to_add.append(sensor)
def _set_result_unless_done(future: asyncio.Future[None]) -> None:
"""Set the result of a future unless it is done."""
@@ -582,196 +495,3 @@ class EnergyCostSensor(SensorEntity):
prefix = self._config[self._adapter.stat_energy_key]
return f"{prefix}_{self._adapter.source_type}_{self._adapter.entity_id_suffix}"
class EnergyPowerSensor(SensorEntity):
"""Transform power sensor values (invert or combine two sensors).
This sensor handles non-standard power sensor configurations for the energy
dashboard by either inverting polarity or combining two positive sensors.
"""
_attr_should_poll = False
_attr_device_class = SensorDeviceClass.POWER
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_has_entity_name = True
def __init__(
self,
source_type: str,
config: PowerConfig,
unique_id: str,
entity_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__()
self._source_type = source_type
self._config: PowerConfig = config
self._attr_unique_id = unique_id
self.entity_id = entity_id
self._source_sensors: list[str] = []
self._is_inverted = "stat_rate_inverted" in config
self._is_combined = "stat_rate_from" in config and "stat_rate_to" in config
# Determine source sensors
if self._is_inverted:
self._source_sensors = [config["stat_rate_inverted"]]
elif self._is_combined:
self._source_sensors = [
config["stat_rate_from"],
config["stat_rate_to"],
]
# add_finished is set when either async_added_to_hass or add_to_platform_abort
# is called
self.add_finished: asyncio.Future[None] = (
asyncio.get_running_loop().create_future()
)
@property
def available(self) -> bool:
"""Return if entity is available."""
if self._is_inverted:
source = self.hass.states.get(self._source_sensors[0])
return source is not None and source.state not in (
"unknown",
"unavailable",
)
if self._is_combined:
discharge = self.hass.states.get(self._source_sensors[0])
charge = self.hass.states.get(self._source_sensors[1])
return (
discharge is not None
and charge is not None
and discharge.state not in ("unknown", "unavailable")
and charge.state not in ("unknown", "unavailable")
)
return True
@callback
def _update_state(self) -> None:
"""Update the sensor state based on source sensors."""
if self._is_inverted:
source_state = self.hass.states.get(self._source_sensors[0])
if source_state is None or source_state.state in ("unknown", "unavailable"):
self._attr_native_value = None
return
try:
value = float(source_state.state)
self._attr_native_value = value * -1
except ValueError:
self._attr_native_value = None
elif self._is_combined:
discharge_state = self.hass.states.get(self._source_sensors[0])
charge_state = self.hass.states.get(self._source_sensors[1])
if (
discharge_state is None
or charge_state is None
or discharge_state.state in ("unknown", "unavailable")
or charge_state.state in ("unknown", "unavailable")
):
self._attr_native_value = None
return
try:
discharge = float(discharge_state.state)
charge = float(charge_state.state)
# Get units from state attributes
discharge_unit = discharge_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT
)
charge_unit = charge_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
# Convert to Watts if units are present
if discharge_unit:
discharge = unit_conversion.PowerConverter.convert(
discharge, discharge_unit, UnitOfPower.WATT
)
if charge_unit:
charge = unit_conversion.PowerConverter.convert(
charge, charge_unit, UnitOfPower.WATT
)
self._attr_native_value = discharge - charge
except ValueError:
self._attr_native_value = None
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
# Set name based on source sensor(s)
if self._source_sensors:
entity_reg = er.async_get(self.hass)
device_id = None
source_name = None
# Check first sensor
if source_entry := entity_reg.async_get(self._source_sensors[0]):
device_id = source_entry.device_id
# For combined mode, always use Watts because we may have different source units; for inverted mode, copy source unit
if self._is_combined:
self._attr_native_unit_of_measurement = UnitOfPower.WATT
else:
self._attr_native_unit_of_measurement = (
source_entry.unit_of_measurement
)
# Get source name from registry
source_name = source_entry.name or source_entry.original_name
# Assign power sensor to same device as source sensor(s)
# Note: We use manual entity registry update instead of _attr_device_info
# because device assignment depends on runtime information from the entity
# registry (which source sensor has a device). This information isn't
# available during __init__, and the entity is already registered before
# async_added_to_hass runs, making the standard _attr_device_info pattern
# incompatible with this use case.
# If first sensor has no device and we have a second sensor, check it
if not device_id and len(self._source_sensors) > 1:
if source_entry := entity_reg.async_get(self._source_sensors[1]):
device_id = source_entry.device_id
# Update entity registry entry with device_id
if device_id and (power_entry := entity_reg.async_get(self.entity_id)):
entity_reg.async_update_entity(
power_entry.entity_id, device_id=device_id
)
else:
self._attr_has_entity_name = False
# Set name for inverted mode
if self._is_inverted:
if source_name:
self._attr_name = f"{source_name} Inverted"
else:
# Fall back to entity_id if no name in registry
sensor_name = split_entity_id(self._source_sensors[0])[1].replace(
"_", " "
)
self._attr_name = f"{sensor_name.title()} Inverted"
# Set name for combined mode
if self._is_combined:
self._attr_name = f"{self._source_type.title()} Power"
self._update_state()
# Track state changes on all source sensors
self.async_on_remove(
async_track_state_change_event(
self.hass,
self._source_sensors,
self._async_state_changed_listener,
)
)
_set_result_unless_done(self.add_finished)
@callback
def _async_state_changed_listener(self, *_: Any) -> None:
"""Handle source sensor state changes."""
self._update_state()
self.async_write_ha_state()
@callback
def add_to_platform_abort(self) -> None:
"""Abort adding an entity to a platform."""
_set_result_unless_done(self.add_finished)
super().add_to_platform_abort()

View File

@@ -206,7 +206,7 @@ class EnvoyProductionSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy production sensor entity."""
value_fn: Callable[[EnvoySystemProduction], int]
on_phase: str | None
on_phase: str | None = None
PRODUCTION_SENSORS = (
@@ -219,7 +219,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="daily_production",
@@ -230,7 +229,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
value_fn=attrgetter("watt_hours_today"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="seven_days_production",
@@ -240,7 +238,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
value_fn=attrgetter("watt_hours_last_7_days"),
on_phase=None,
),
EnvoyProductionSensorEntityDescription(
key="lifetime_production",
@@ -251,7 +248,6 @@ PRODUCTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -277,7 +273,7 @@ class EnvoyConsumptionSensorEntityDescription(SensorEntityDescription):
"""Describes an Envoy consumption sensor entity."""
value_fn: Callable[[EnvoySystemConsumption], int]
on_phase: str | None
on_phase: str | None = None
CONSUMPTION_SENSORS = (
@@ -290,7 +286,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="daily_consumption",
@@ -301,7 +296,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
value_fn=attrgetter("watt_hours_today"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="seven_days_consumption",
@@ -311,7 +305,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
value_fn=attrgetter("watt_hours_last_7_days"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="lifetime_consumption",
@@ -322,7 +315,6 @@ CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -354,7 +346,6 @@ NET_CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("watts_now"),
on_phase=None,
),
EnvoyConsumptionSensorEntityDescription(
key="lifetime_balanced_net_consumption",
@@ -366,7 +357,6 @@ NET_CONSUMPTION_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("watt_hours_lifetime"),
on_phase=None,
),
)
@@ -395,7 +385,7 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
[EnvoyMeterData],
int | float | str | CtType | CtMeterStatus | CtStatusFlags | CtState | None,
]
on_phase: str | None
on_phase: str | None = None
cttype: str | None = None
@@ -411,7 +401,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -430,7 +419,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -449,7 +437,6 @@ CT_SENSORS = (
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -468,7 +455,6 @@ CT_SENSORS = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -488,7 +474,6 @@ CT_SENSORS = (
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -508,7 +493,6 @@ CT_SENSORS = (
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -526,7 +510,6 @@ CT_SENSORS = (
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
@@ -544,7 +527,6 @@ CT_SENSORS = (
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -565,7 +547,6 @@ CT_SENSORS = (
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
@@ -783,7 +764,7 @@ ENCHARGE_AGGREGATE_SENSORS = (
translation_key="available_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("available_energy"),
),
EnvoyEnchargeAggregateSensorEntityDescription(
@@ -791,14 +772,14 @@ ENCHARGE_AGGREGATE_SENSORS = (
translation_key="reserve_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.ENERGY,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("backup_reserve"),
),
EnvoyEnchargeAggregateSensorEntityDescription(
key="max_capacity",
translation_key="max_capacity",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
device_class=SensorDeviceClass.ENERGY_STORAGE,
value_fn=attrgetter("max_available_capacity"),
),
)

View File

@@ -77,9 +77,14 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
)
LOGGER.debug("enable smarthome templates: %s", self.has_templates)
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
try:
self.has_triggers = await self.hass.async_add_executor_job(
self.fritz.has_triggers
)
except HTTPError:
# Fritz!OS < 7.39 just don't have this api endpoint
# so we need to fetch the HTTPError here and assume no triggers
self.has_triggers = False
LOGGER.debug("enable smarthome triggers: %s", self.has_triggers)
self.configuration_url = self.fritz.get_prefixed_host()

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251229.1"]
"requirements": ["home-assistant-frontend==20260107.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==2.0.2"]
"requirements": ["google_air_quality_api==2.1.2"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
"requirements": ["greeclimate==2.1.0"]
"requirements": ["greeclimate==2.1.1"]
}

View File

@@ -0,0 +1,31 @@
"""The HDFury Integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.BUTTON,
Platform.SELECT,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: HDFuryConfigEntry) -> bool:
"""Set up HDFury as config entry."""
coordinator = HDFuryCoordinator(hass, entry)
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: HDFuryConfigEntry) -> bool:
"""Unload a HDFury config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,74 @@
"""Button platform for HDFury Integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from hdfury import HDFuryAPI, HDFuryError
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
@dataclass(kw_only=True, frozen=True)
class HDFuryButtonEntityDescription(ButtonEntityDescription):
"""Description for HDFury button entities."""
press_fn: Callable[[HDFuryAPI], Awaitable[None]]
BUTTONS: tuple[HDFuryButtonEntityDescription, ...] = (
HDFuryButtonEntityDescription(
key="reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.issue_reboot(),
),
HDFuryButtonEntityDescription(
key="issue_hotplug",
translation_key="issue_hotplug",
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.issue_hotplug(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons using the platform schema."""
coordinator = entry.runtime_data
async_add_entities(
HDFuryButton(coordinator, description) for description in BUTTONS
)
class HDFuryButton(HDFuryEntity, ButtonEntity):
"""HDFury Button Class."""
entity_description: HDFuryButtonEntityDescription
async def async_press(self) -> None:
"""Handle Button Press."""
try:
await self.entity_description.press_fn(self.coordinator.client)
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error

View File

@@ -0,0 +1,54 @@
"""Config flow for HDFury Integration."""
from typing import Any
from hdfury import HDFuryAPI, HDFuryError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
class HDFuryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle Config Flow for HDFury."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle Initial Setup."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
serial = await self._validate_connection(host)
if serial is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"HDFury ({host})", data=user_input
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def _validate_connection(self, host: str) -> str | None:
"""Try to fetch serial number to confirm it's a valid HDFury device."""
client = HDFuryAPI(host, async_get_clientsession(self.hass))
try:
data = await client.get_board()
except HDFuryError:
return None
return data["serial"]

View File

@@ -0,0 +1,3 @@
"""Constants for HDFury Integration."""
DOMAIN = "hdfury"

View File

@@ -0,0 +1,67 @@
"""DataUpdateCoordinator for HDFury Integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Final
from hdfury import HDFuryAPI, HDFuryError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL: Final = timedelta(seconds=60)
type HDFuryConfigEntry = ConfigEntry[HDFuryCoordinator]
@dataclass(kw_only=True, frozen=True)
class HDFuryData:
"""HDFury Data Class."""
board: dict[str, str]
info: dict[str, str]
config: dict[str, str]
class HDFuryCoordinator(DataUpdateCoordinator[HDFuryData]):
"""HDFury Device Coordinator Class."""
def __init__(self, hass: HomeAssistant, entry: HDFuryConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name="HDFury",
update_interval=SCAN_INTERVAL,
)
self.host: str = entry.data[CONF_HOST]
self.client = HDFuryAPI(self.host, async_get_clientsession(hass))
async def _async_update_data(self) -> HDFuryData:
"""Fetch the latest device data."""
try:
board = await self.client.get_board()
info = await self.client.get_info()
config = await self.client.get_config()
except HDFuryError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
return HDFuryData(
board=board,
info=info,
config=config,
)

View File

@@ -0,0 +1,39 @@
"""Base class for HDFury entities."""
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import HDFuryCoordinator
class HDFuryEntity(CoordinatorEntity[HDFuryCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HDFuryCoordinator, entity_description: EntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.data.board['serial']}_{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
name=f"HDFury {coordinator.data.board['hostname']}",
manufacturer="HDFury",
model=coordinator.data.board["hostname"].split("-")[0],
serial_number=coordinator.data.board["serial"],
sw_version=coordinator.data.board["version"].removeprefix("FW: "),
hw_version=coordinator.data.board.get("pcbv"),
configuration_url=f"http://{coordinator.host}",
connections={
(dr.CONNECTION_NETWORK_MAC, coordinator.data.config["macaddr"])
},
)

View File

@@ -0,0 +1,52 @@
{
"entity": {
"button": {
"issue_hotplug": {
"default": "mdi:connection"
}
},
"select": {
"opmode": {
"default": "mdi:cogs"
},
"portseltx0": {
"default": "mdi:hdmi-port"
},
"portseltx1": {
"default": "mdi:hdmi-port"
}
},
"switch": {
"autosw": {
"default": "mdi:import"
},
"htpcmode0": {
"default": "mdi:desktop-classic"
},
"htpcmode1": {
"default": "mdi:desktop-classic"
},
"htpcmode2": {
"default": "mdi:desktop-classic"
},
"htpcmode3": {
"default": "mdi:desktop-classic"
},
"iractive": {
"default": "mdi:remote"
},
"mutetx0": {
"default": "mdi:volume-mute"
},
"mutetx1": {
"default": "mdi:volume-mute"
},
"oled": {
"default": "mdi:cellphone-information"
},
"relay": {
"default": "mdi:electric-switch"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "hdfury",
"name": "HDFury",
"codeowners": ["@glenndehaan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["hdfury==1.3.1"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Integration has no authentication flow.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Device type integration.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Device type integration.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,122 @@
"""Select platform for HDFury Integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from hdfury import (
OPERATION_MODES,
TX0_INPUT_PORTS,
TX1_INPUT_PORTS,
HDFuryAPI,
HDFuryError,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
from .entity import HDFuryEntity
@dataclass(kw_only=True, frozen=True)
class HDFurySelectEntityDescription(SelectEntityDescription):
"""Description for HDFury select entities."""
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
SELECT_PORTS: tuple[HDFurySelectEntityDescription, ...] = (
HDFurySelectEntityDescription(
key="portseltx0",
translation_key="portseltx0",
options=list(TX0_INPUT_PORTS.keys()),
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
),
HDFurySelectEntityDescription(
key="portseltx1",
translation_key="portseltx1",
options=list(TX1_INPUT_PORTS.keys()),
set_value_fn=lambda coordinator, value: _set_ports(coordinator),
),
)
SELECT_OPERATION_MODE: HDFurySelectEntityDescription = HDFurySelectEntityDescription(
key="opmode",
translation_key="opmode",
options=list(OPERATION_MODES.keys()),
set_value_fn=lambda coordinator, value: coordinator.client.set_operation_mode(
value
),
)
async def _set_ports(coordinator: HDFuryCoordinator) -> None:
tx0 = coordinator.data.info.get("portseltx0")
tx1 = coordinator.data.info.get("portseltx1")
if tx0 is None or tx1 is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="tx_state_error",
translation_placeholders={"details": f"tx0={tx0}, tx1={tx1}"},
)
await coordinator.client.set_port_selection(tx0, tx1)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up selects using the platform schema."""
coordinator = entry.runtime_data
entities: list[HDFuryEntity] = []
for description in SELECT_PORTS:
if description.key not in coordinator.data.info:
continue
entities.append(HDFurySelect(coordinator, description))
# Add OPMODE select if present
if "opmode" in coordinator.data.info:
entities.append(HDFurySelect(coordinator, SELECT_OPERATION_MODE))
async_add_entities(entities)
class HDFurySelect(HDFuryEntity, SelectEntity):
"""HDFury Select Class."""
entity_description: HDFurySelectEntityDescription
@property
def current_option(self) -> str:
"""Return the current option."""
return self.coordinator.data.info[self.entity_description.key]
async def async_select_option(self, option: str) -> None:
"""Update the current option."""
# Update local data first
self.coordinator.data.info[self.entity_description.key] = option
# Send command to device
try:
await self.entity_description.set_value_fn(self.coordinator, option)
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
# Trigger HA coordinator refresh
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,101 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Hostname or IP address of your HDFury device."
},
"description": "Set up your HDFury to integrate with Home Assistant."
}
}
},
"entity": {
"button": {
"issue_hotplug": {
"name": "Issue hotplug"
}
},
"select": {
"opmode": {
"name": "Operation mode",
"state": {
"0": "Mode 0 - Splitter TX0/TX1 FRL5 VRR",
"1": "Mode 1 - Splitter TX0/TX1 UPSCALE FRL5",
"2": "Mode 2 - Matrix TMDS",
"3": "Mode 3 - Matrix FRL->TMDS",
"4": "Mode 4 - Matrix DOWNSCALE",
"5": "Mode 5 - Matrix RX0:FRL5 + RX1-3:TMDS"
}
},
"portseltx0": {
"name": "Port select TX0",
"state": {
"0": "Input 0",
"1": "Input 1",
"2": "Input 2",
"3": "Input 3",
"4": "Copy TX1"
}
},
"portseltx1": {
"name": "Port select TX1",
"state": {
"0": "Input 0",
"1": "Input 1",
"2": "Input 2",
"3": "Input 3",
"4": "Copy TX0"
}
}
},
"switch": {
"autosw": {
"name": "Auto switch inputs"
},
"htpcmode0": {
"name": "HTPC mode RX0"
},
"htpcmode1": {
"name": "HTPC mode RX1"
},
"htpcmode2": {
"name": "HTPC mode RX2"
},
"htpcmode3": {
"name": "HTPC mode RX3"
},
"iractive": {
"name": "Infrared"
},
"mutetx0": {
"name": "Mute audio TX0"
},
"mutetx1": {
"name": "Mute audio TX1"
},
"oled": {
"name": "OLED display"
},
"relay": {
"name": "Relay"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with HDFury device"
},
"tx_state_error": {
"message": "An error occurred while validating TX states: {details}"
}
}
}

View File

@@ -0,0 +1,142 @@
"""Switch platform for HDFury Integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from hdfury import HDFuryAPI, HDFuryError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
@dataclass(kw_only=True, frozen=True)
class HDFurySwitchEntityDescription(SwitchEntityDescription):
"""Description for HDFury switch entities."""
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
SWITCHES: tuple[HDFurySwitchEntityDescription, ...] = (
HDFurySwitchEntityDescription(
key="autosw",
translation_key="autosw",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_auto_switch_inputs(value),
),
HDFurySwitchEntityDescription(
key="htpcmode0",
translation_key="htpcmode0",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_htpc_mode_rx0(value),
),
HDFurySwitchEntityDescription(
key="htpcmode1",
translation_key="htpcmode1",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_htpc_mode_rx1(value),
),
HDFurySwitchEntityDescription(
key="htpcmode2",
translation_key="htpcmode2",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_htpc_mode_rx2(value),
),
HDFurySwitchEntityDescription(
key="htpcmode3",
translation_key="htpcmode3",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_htpc_mode_rx3(value),
),
HDFurySwitchEntityDescription(
key="mutetx0",
translation_key="mutetx0",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_mute_tx0_audio(value),
),
HDFurySwitchEntityDescription(
key="mutetx1",
translation_key="mutetx1",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_mute_tx1_audio(value),
),
HDFurySwitchEntityDescription(
key="oled",
translation_key="oled",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_oled(value),
),
HDFurySwitchEntityDescription(
key="iractive",
translation_key="iractive",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_ir_active(value),
),
HDFurySwitchEntityDescription(
key="relay",
translation_key="relay",
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_relay(value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches using the platform schema."""
coordinator = entry.runtime_data
async_add_entities(
HDFurySwitch(coordinator, description)
for description in SWITCHES
if description.key in coordinator.data.config
)
class HDFurySwitch(HDFuryEntity, SwitchEntity):
"""Base HDFury Switch Class."""
entity_description: HDFurySwitchEntityDescription
@property
def is_on(self) -> bool:
"""Set Switch State."""
return self.coordinator.data.config.get(self.entity_description.key) == "1"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Handle Switch On Event."""
try:
await self.entity_description.set_value_fn(self.coordinator.client, "on")
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Handle Switch Off Event."""
try:
await self.entity_description.set_value_fn(self.coordinator.client, "off")
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
await self.coordinator.async_request_refresh()

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.3.4"]
"requirements": ["pyHik==0.4.0"]
}

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.26.0"],
"requirements": ["aiohomeconnect==0.28.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -15,7 +15,7 @@ set_program_and_options:
- active_program
- selected_program
program:
example: dishcare_dishwasher_program_auto2
example: dishcare_dishwasher_program_auto_2
selector:
select:
mode: dropdown
@@ -73,6 +73,7 @@ set_program_and_options:
- dishcare_dishwasher_program_intensiv_45
- dishcare_dishwasher_program_auto_half_load
- dishcare_dishwasher_program_intensiv_power
- dishcare_dishwasher_program_intensive_fixed_zone
- dishcare_dishwasher_program_magic_daily
- dishcare_dishwasher_program_super_60
- dishcare_dishwasher_program_kurz_60
@@ -121,6 +122,7 @@ set_program_and_options:
- cooking_oven_program_heating_mode_pre_heating
- cooking_oven_program_heating_mode_hot_air
- cooking_oven_program_heating_mode_hot_air_eco
- cooking_oven_program_heating_mode_hot_air_gentle
- cooking_oven_program_heating_mode_hot_air_grilling
- cooking_oven_program_heating_mode_top_bottom_heating
- cooking_oven_program_heating_mode_top_bottom_heating_eco
@@ -147,6 +149,7 @@ set_program_and_options:
- cooking_oven_program_microwave_900_watt
- cooking_oven_program_microwave_1000_watt
- cooking_oven_program_microwave_max
- cooking_oven_program_steam_modes_steam
- cooking_oven_program_heating_mode_warming_drawer
- laundry_care_washer_program_auto_30
- laundry_care_washer_program_auto_40
@@ -174,7 +177,7 @@ set_program_and_options:
- laundry_care_washer_program_rinse_rinse_spin_drain
- laundry_care_washer_program_sensitive
- laundry_care_washer_program_shirts_blouses
- laundry_care_washer_program_spin_drain
- laundry_care_washer_program_spin_spin_drain
- laundry_care_washer_program_sport_fitness
- laundry_care_washer_program_super_153045_super_15
- laundry_care_washer_program_super_153045_super_1530

View File

@@ -240,6 +240,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -271,6 +272,7 @@
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
@@ -350,7 +352,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -592,6 +594,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
"cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
"cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
"cooking_oven_program_heating_mode_hot_air_gentle": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_gentle%]",
"cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
"cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
"cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
@@ -612,6 +615,7 @@
"cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
"cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
"cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
"cooking_oven_program_steam_modes_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_steam_modes_steam%]",
"dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
"dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
"dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
@@ -623,6 +627,7 @@
"dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
"dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
"dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
"dishcare_dishwasher_program_intensive_fixed_zone": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensive_fixed_zone%]",
"dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
"dishcare_dishwasher_program_learning_dishwasher": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_learning_dishwasher%]",
"dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
@@ -702,7 +707,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
"laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
"laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
"laundry_care_washer_program_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_drain%]",
"laundry_care_washer_program_spin_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_spin_spin_drain%]",
"laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
"laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
"laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
@@ -1583,6 +1588,7 @@
"cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
"cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
"cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
"cooking_oven_program_heating_mode_hot_air_gentle": "Hot air gentle",
"cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
"cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
"cooking_oven_program_heating_mode_keep_warm": "Keep warm",
@@ -1603,6 +1609,7 @@
"cooking_oven_program_microwave_900_watt": "900 Watt",
"cooking_oven_program_microwave_90_watt": "90 Watt",
"cooking_oven_program_microwave_max": "Max",
"cooking_oven_program_steam_modes_steam": "Steam mode",
"dishcare_dishwasher_program_auto_1": "Auto 1",
"dishcare_dishwasher_program_auto_2": "Auto 2",
"dishcare_dishwasher_program_auto_3": "Auto 3",
@@ -1614,6 +1621,7 @@
"dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
"dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
"dishcare_dishwasher_program_intensiv_power": "Intensive power",
"dishcare_dishwasher_program_intensive_fixed_zone": "Intensive fixed zone",
"dishcare_dishwasher_program_kurz_60": "Speed 60ºC",
"dishcare_dishwasher_program_learning_dishwasher": "Intelligent",
"dishcare_dishwasher_program_machine_care": "Machine care",
@@ -1693,7 +1701,7 @@
"laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
"laundry_care_washer_program_sensitive": "Sensitive",
"laundry_care_washer_program_shirts_blouses": "Shirts/blouses",
"laundry_care_washer_program_spin_drain": "Spin/drain",
"laundry_care_washer_program_spin_spin_drain": "Spin/drain",
"laundry_care_washer_program_sport_fitness": "Sport/fitness",
"laundry_care_washer_program_super_153045_super_15": "Super 15 min",
"laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"serialx==0.5.0",
"serialx==0.6.2",
"universal-silabs-flasher==0.1.2",
"ha-silabs-firmware-client==0.3.0"
]

View File

@@ -34,6 +34,7 @@ from .const import ( # noqa: F401
ATTR_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_MIN_HUMIDITY,
ATTR_TARGET_HUMIDITY_STEP,
DEFAULT_MAX_HUMIDITY,
DEFAULT_MIN_HUMIDITY,
DOMAIN,
@@ -141,6 +142,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"min_humidity",
"max_humidity",
"supported_features",
"target_humidity_step",
}
@@ -148,7 +150,12 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
"""Base class for humidifier entities."""
_entity_component_unrecorded_attributes = frozenset(
{ATTR_MIN_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_AVAILABLE_MODES}
{
ATTR_MIN_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_AVAILABLE_MODES,
ATTR_TARGET_HUMIDITY_STEP,
}
)
entity_description: HumidifierEntityDescription
@@ -161,6 +168,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
_attr_mode: str | None
_attr_supported_features: HumidifierEntityFeature = HumidifierEntityFeature(0)
_attr_target_humidity: float | None = None
_attr_target_humidity_step: float | None = None
@property
def capability_attributes(self) -> dict[str, Any]:
@@ -169,6 +177,8 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
ATTR_MIN_HUMIDITY: self.min_humidity,
ATTR_MAX_HUMIDITY: self.max_humidity,
}
if self.target_humidity_step is not None:
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
if HumidifierEntityFeature.MODES in self.supported_features:
data[ATTR_AVAILABLE_MODES] = self.available_modes
@@ -251,6 +261,11 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
"""Set new mode."""
await self.hass.async_add_executor_job(self.set_mode, mode)
@cached_property
def target_humidity_step(self) -> float | None:
"""Return the supported step of humidity."""
return self._attr_target_humidity_step
@cached_property
def min_humidity(self) -> float:
"""Return the minimum humidity."""

View File

@@ -28,6 +28,7 @@ ATTR_CURRENT_HUMIDITY = "current_humidity"
ATTR_HUMIDITY = "humidity"
ATTR_MAX_HUMIDITY = "max_humidity"
ATTR_MIN_HUMIDITY = "min_humidity"
ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
DEFAULT_MIN_HUMIDITY = 0
DEFAULT_MAX_HUMIDITY = 100

View File

@@ -19,6 +19,10 @@
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
@@ -27,14 +31,11 @@
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:

View File

@@ -12,5 +12,5 @@
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
"quality_scale": "platinum",
"requirements": ["incomfort-client==0.6.10"]
"requirements": ["incomfort-client==0.6.11"]
}

View File

@@ -42,6 +42,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.EVENT,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,

View File

@@ -0,0 +1,195 @@
"""Support for humidifier entities."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from thinqconnect import DeviceType
from thinqconnect.devices.const import Property as ThinQProperty
from thinqconnect.integration import ActiveMode
from homeassistant.components.humidifier import (
HumidifierAction,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityDescription,
HumidifierEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .coordinator import DeviceDataUpdateCoordinator
from .entity import ThinQEntity
@dataclass(frozen=True, kw_only=True)
class ThinQHumidifierEntityDescription(HumidifierEntityDescription):
"""Describes ThinQ humidifier entity."""
current_humidity_key: str
operation_key: str
mode_key: str = ThinQProperty.CURRENT_JOB_MODE
DEVICE_TYPE_HUM_MAP: dict[DeviceType, ThinQHumidifierEntityDescription] = {
DeviceType.DEHUMIDIFIER: ThinQHumidifierEntityDescription(
key=ThinQProperty.TARGET_HUMIDITY,
name=None,
device_class=HumidifierDeviceClass.DEHUMIDIFIER,
translation_key="dehumidifier",
current_humidity_key=ThinQProperty.CURRENT_HUMIDITY,
operation_key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE,
),
DeviceType.HUMIDIFIER: ThinQHumidifierEntityDescription(
key=ThinQProperty.TARGET_HUMIDITY,
name=None,
device_class=HumidifierDeviceClass.HUMIDIFIER,
translation_key="humidifier",
current_humidity_key=ThinQProperty.HUMIDITY,
operation_key=ThinQProperty.HUMIDIFIER_OPERATION_MODE,
),
}
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for humidifier platform."""
entities: list[ThinQHumidifierEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
description := DEVICE_TYPE_HUM_MAP.get(coordinator.api.device.device_type)
) is not None:
entities.extend(
ThinQHumidifierEntity(coordinator, description, property_id)
for property_id in coordinator.api.get_active_idx(
description.key, ActiveMode.READ_WRITE
)
)
if entities:
async_add_entities(entities)
class ThinQHumidifierEntity(ThinQEntity, HumidifierEntity):
"""Represent a ThinQ humidifier entity."""
entity_description: ThinQHumidifierEntityDescription
_attr_supported_features = HumidifierEntityFeature.MODES
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
entity_description: ThinQHumidifierEntityDescription,
property_id: str,
) -> None:
"""Initialize a humidifier entity."""
super().__init__(coordinator, entity_description, property_id)
self._attr_available_modes = self.coordinator.data[
self.entity_description.mode_key
].options
if self.data.max is not None:
self._attr_max_humidity = self.data.max
if self.data.min is not None:
self._attr_min_humidity = self.data.min
self._attr_target_humidity_step = (
self.data.step if self.data.step is not None else 1
)
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
self._attr_target_humidity = self.data.value
self._attr_current_humidity = self.coordinator.data[
self.entity_description.current_humidity_key
].value
self._attr_is_on = self.coordinator.data[
self.entity_description.operation_key
].is_on
self._attr_mode = self.coordinator.data[self.entity_description.mode_key].value
if self.is_on:
self._attr_action = (
HumidifierAction.DRYING
if self.entity_description.device_class
== HumidifierDeviceClass.DEHUMIDIFIER
else HumidifierAction.HUMIDIFYING
)
else:
self._attr_action = HumidifierAction.OFF
_LOGGER.debug(
"[%s:%s] update status: c:%s, t:%s, mode:%s, action:%s, is_on:%s",
self.coordinator.device_name,
self.property_id,
self.current_humidity,
self.target_humidity,
self.mode,
self.action,
self.is_on,
)
async def async_set_mode(self, mode: str) -> None:
"""Set new target preset mode."""
_LOGGER.debug(
"[%s:%s] async_set_mode: %s",
self.coordinator.device_name,
self.entity_description.mode_key,
mode,
)
await self.async_call_api(
self.coordinator.api.post(self.entity_description.mode_key, mode)
)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
_target_humidity = round(humidity / (self.target_humidity_step or 1)) * (
self.target_humidity_step or 1
)
_LOGGER.debug(
"[%s:%s] async_set_humidity: %s, target_humidity: %s, step: %s",
self.coordinator.device_name,
self.property_id,
humidity,
_target_humidity,
self.target_humidity_step,
)
if _target_humidity == self.target_humidity:
return
await self.async_call_api(
self.coordinator.api.post(self.property_id, _target_humidity)
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if self.is_on:
return
_LOGGER.debug(
"[%s:%s] async_turn_on",
self.coordinator.device_name,
self.entity_description.operation_key,
)
await self.async_call_api(
self.coordinator.api.async_turn_on(self.entity_description.operation_key)
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
if not self.is_on:
return
_LOGGER.debug(
"[%s:%s] async_turn_off",
self.coordinator.device_name,
self.entity_description.operation_key,
)
await self.async_call_api(
self.coordinator.api.async_turn_off(self.entity_description.operation_key)
)

View File

@@ -199,6 +199,33 @@
}
}
},
"humidifier": {
"dehumidifier": {
"state_attributes": {
"mode": {
"state": {
"air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]",
"clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]",
"intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]",
"quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]",
"rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]",
"smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]"
}
}
}
},
"humidifier": {
"state_attributes": {
"mode": {
"state": {
"air_clean": "[%key:component::lg_thinq::entity::select::current_job_mode::state::air_clean%]",
"humidify": "[%key:component::lg_thinq::entity::select::current_job_mode::state::humidify%]",
"humidify_and_air_clean": "[%key:component::lg_thinq::entity::select::current_job_mode::state::humidify_and_air_clean%]"
}
}
}
}
},
"number": {
"fan_speed": {
"name": "Fan"

View File

@@ -19,6 +19,10 @@
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
@@ -27,10 +31,6 @@
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
turned_on: *trigger_common
@@ -48,6 +48,7 @@ brightness_crossed_threshold:
behavior: *trigger_behavior
threshold_type:
required: true
default: above
selector:
select:
options:

View File

@@ -1,6 +1,6 @@
{
"domain": "namecheapdns",
"name": "Namecheap FreeDNS",
"name": "Namecheap DynamicDNS",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/namecheapdns",
"iot_class": "cloud_push",

View File

@@ -1,5 +1,9 @@
"""Support for Netatmo binary sensors."""
from dataclasses import dataclass
import logging
from typing import Final, cast
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -9,17 +13,33 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import NETATMO_CREATE_WEATHER_SENSOR
from .const import NETATMO_CREATE_WEATHER_BINARY_SENSOR
from .data_handler import NetatmoDevice
from .entity import NetatmoWeatherModuleEntity
BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Netatmo binary sensor entity."""
name: str | None = None # The default name of the sensor
netatmo_name: str # The name used by Netatmo API for this sensor
NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS: Final[
list[NetatmoBinarySensorEntityDescription]
] = [
NetatmoBinarySensorEntityDescription(
key="reachable",
name="Connectivity",
netatmo_name="reachable",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
)
]
async def async_setup_entry(
@@ -27,36 +47,75 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Netatmo binary sensors based on a config entry."""
"""Set up Netatmo weather binary sensors based on a config entry."""
@callback
def _create_weather_binary_sensor_entity(netatmo_device: NetatmoDevice) -> None:
async_add_entities(
NetatmoWeatherBinarySensor(netatmo_device, description)
for description in BINARY_SENSOR_TYPES
if description.key in netatmo_device.device.features
)
"""Create weather binary sensor entities for a Netatmo weather device."""
descriptions_to_add = NETATMO_WEATHER_BINARY_SENSOR_DESCRIPTIONS
entities: list[NetatmoWeatherBinarySensor] = []
# Create binary sensors for module
for description in descriptions_to_add:
# Actual check is simple for reachable
feature_check = description.key
if feature_check in netatmo_device.device.features:
_LOGGER.debug(
'Adding "%s" weather binary sensor for device %s',
feature_check,
netatmo_device.device.name,
)
entities.append(
NetatmoWeatherBinarySensor(
netatmo_device,
description,
)
)
if entities:
async_add_entities(entities)
entry.async_on_unload(
async_dispatcher_connect(
hass, NETATMO_CREATE_WEATHER_SENSOR, _create_weather_binary_sensor_entity
hass,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
_create_weather_binary_sensor_entity,
)
)
class NetatmoWeatherBinarySensor(NetatmoWeatherModuleEntity, BinarySensorEntity):
"""Implementation of a Netatmo binary sensor."""
"""Implementation of a Netatmo weather binary sensor."""
entity_description: NetatmoBinarySensorEntityDescription
def __init__(
self, device: NetatmoDevice, description: BinarySensorEntityDescription
self,
netatmo_device: NetatmoDevice,
description: NetatmoBinarySensorEntityDescription,
) -> None:
"""Initialize a Netatmo binary sensor."""
super().__init__(device)
"""Initialize a Netatmo weather binary sensor."""
super().__init__(netatmo_device)
self.entity_description = description
self._attr_unique_id = f"{self.device.entity_id}-{description.key}"
@callback
def async_update_callback(self) -> None:
"""Update the entity's state."""
self._attr_is_on = self.device.reachable
value: StateType | None = None
value = getattr(self.device, self.entity_description.netatmo_name, None)
if value is None:
self._attr_available = False
self._attr_is_on = False
else:
self._attr_available = True
self._attr_is_on = cast(bool, value)
self.async_write_ha_state()

View File

@@ -53,6 +53,7 @@ NETATMO_CREATE_ROOM_SENSOR = "netatmo_create_room_sensor"
NETATMO_CREATE_SELECT = "netatmo_create_select"
NETATMO_CREATE_SENSOR = "netatmo_create_sensor"
NETATMO_CREATE_SWITCH = "netatmo_create_switch"
NETATMO_CREATE_WEATHER_BINARY_SENSOR = "netatmo_create_weather_binary_sensor"
NETATMO_CREATE_WEATHER_SENSOR = "netatmo_create_weather_sensor"
CONF_AREA_NAME = "area_name"

View File

@@ -45,6 +45,7 @@ from .const import (
NETATMO_CREATE_SELECT,
NETATMO_CREATE_SENSOR,
NETATMO_CREATE_SWITCH,
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
NETATMO_CREATE_WEATHER_SENSOR,
PLATFORMS,
WEBHOOK_ACTIVATION,
@@ -332,16 +333,20 @@ class NetatmoDataHandler:
"""Set up home coach/air care modules."""
for module in self.account.modules.values():
if module.device_category is NetatmoDeviceCategory.air_care:
async_dispatcher_send(
self.hass,
for signal in (
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
NETATMO_CREATE_WEATHER_SENSOR,
NetatmoDevice(
self,
module,
AIR_CARE,
AIR_CARE,
),
)
):
async_dispatcher_send(
self.hass,
signal,
NetatmoDevice(
self,
module,
AIR_CARE,
AIR_CARE,
),
)
def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None:
"""Set up modules."""
@@ -379,16 +384,20 @@ class NetatmoDataHandler:
),
)
if module.device_category is NetatmoDeviceCategory.weather:
async_dispatcher_send(
self.hass,
for signal in (
NETATMO_CREATE_WEATHER_BINARY_SENSOR,
NETATMO_CREATE_WEATHER_SENSOR,
NetatmoDevice(
self,
module,
home.entity_id,
WEATHER,
),
)
):
async_dispatcher_send(
self.hass,
signal,
NetatmoDevice(
self,
module,
home.entity_id,
WEATHER,
),
)
def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None:
"""Set up rooms."""

View File

@@ -256,7 +256,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if slot_id > user_input[CONF_MESSAGE_SLOTS]
]
removed_entites_area = [
removed_entities_area = [
f"{cfg_region}-{slot_id}"
for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1)
for cfg_region in self.data[CONF_REGIONS]
@@ -265,7 +265,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
for entry in entries:
for entity_uid in list(
set(removed_entities_slots + removed_entites_area)
set(removed_entities_slots + removed_entities_area)
):
if entry.unique_id == entity_uid:
entity_registry.async_remove(entry.entity_id)

View File

@@ -7,6 +7,7 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pynina"],
"quality_scale": "bronze",
"requirements": ["pynina==0.3.6"],
"single_config_entry": true
}

View File

@@ -0,0 +1,103 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not use services that need authentication.
test-coverage:
status: todo
comment: |
Use load_json_object_fixture in tests
Patch the library instead of the HTTP requests
Create a shared fixture for the mock config entry
Use init_integration in tests
Evaluate the need of test_config_entry_not_ready
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: |
This integration does use a cloud service.
discovery:
status: exempt
comment: |
This integration does use a cloud service.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: |
This integration does not use devices.
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: done
entity-category: todo
entity-device-class:
status: todo
comment: |
Extract attributes into own entities.
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations:
status: exempt
comment: |
This integration does not custom icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not use issues.
stale-devices:
status: exempt
comment: |
This integration does not use devices.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pynintendoauth", "pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.0"]
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
}

View File

@@ -28,9 +28,15 @@
"exchange_rate": {
"default": "mdi:currency-usd"
},
"highest_price": {
"default": "mdi:cash-plus"
},
"last_price": {
"default": "mdi:cash"
},
"lowest_price": {
"default": "mdi:cash-minus"
},
"next_price": {
"default": "mdi:cash"
},

View File

@@ -6,9 +6,10 @@ from openevsehttp.__main__ import OpenEVSE
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.helpers.service_info import zeroconf
from .const import DOMAIN
from .const import CONF_ID, CONF_SERIAL, DOMAIN
class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -17,27 +18,33 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def check_status(self, host: str) -> bool:
def __init__(self) -> None:
"""Set up the instance."""
self.discovery_info: dict[str, Any] = {}
async def check_status(self, host: str) -> tuple[bool, str | None]:
"""Check if we can connect to the OpenEVSE charger."""
charger = OpenEVSE(host)
try:
await charger.test_and_get()
result = await charger.test_and_get()
except TimeoutError:
return False
else:
return True
return False, None
return True, result.get(CONF_SERIAL)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = None
errors = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if await self.check_status(user_input[CONF_HOST]):
if (result := await self.check_status(user_input[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"OpenEVSE {user_input[CONF_HOST]}",
data=user_input,
@@ -55,10 +62,53 @@ class OpenEVSEConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match({CONF_HOST: data[CONF_HOST]})
if not await self.check_status(data[CONF_HOST]):
if (result := await self.check_status(data[CONF_HOST]))[0]:
if (serial := result[1]) is not None:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
else:
return self.async_abort(reason="unavailable_host")
return self.async_create_entry(
title=f"OpenEVSE {data[CONF_HOST]}",
data=data,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self._async_abort_entries_match({CONF_HOST: discovery_info.host})
await self.async_set_unique_id(discovery_info.properties[CONF_ID])
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
host = discovery_info.host
name = f"OpenEVSE {discovery_info.name.split('.')[0]}"
self.discovery_info.update(
{
CONF_HOST: host,
CONF_NAME: name,
}
)
self.context.update({"title_placeholders": {"name": name}})
if not (await self.check_status(host))[0]:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is None:
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self.discovery_info[CONF_NAME]},
)
return self.async_create_entry(
title=self.discovery_info[CONF_NAME],
data={CONF_HOST: self.discovery_info[CONF_HOST]},
)

View File

@@ -1,4 +1,6 @@
"""Constants for the OpenEVSE integration."""
CONF_ID = "id"
CONF_SERIAL = "serial"
DOMAIN = "openevse"
INTEGRATION_TITLE = "OpenEVSE"

View File

@@ -1,12 +1,14 @@
{
"domain": "openevse",
"name": "OpenEVSE",
"codeowners": ["@c00w"],
"after_dependencies": ["zeroconf"],
"codeowners": ["@c00w", "@firstof9"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/openevse",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["openevsehttp"],
"quality_scale": "legacy",
"requirements": ["python-openevse-http==0.2.1"]
"requirements": ["python-openevse-http==0.2.1"],
"zeroconf": ["_openevse._tcp.local."]
}

View File

@@ -17,6 +17,8 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
ATTR_CONNECTIONS,
ATTR_SERIAL_NUMBER,
CONF_HOST,
CONF_MONITORED_VARIABLES,
UnitOfEnergy,
@@ -26,6 +28,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -40,25 +43,25 @@ _LOGGER = logging.getLogger(__name__)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="status",
name="Charging Status",
translation_key="status",
),
SensorEntityDescription(
key="charge_time",
name="Charge Time Elapsed",
translation_key="charge_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="ambient_temp",
name="Ambient Temperature",
translation_key="ambient_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="ir_temp",
name="IR Temperature",
translation_key="ir_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
@@ -66,7 +69,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
),
SensorEntityDescription(
key="rtc_temp",
name="RTC Temperature",
translation_key="rtc_temp",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
@@ -74,14 +77,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
),
SensorEntityDescription(
key="usage_session",
name="Usage this Session",
translation_key="usage_session",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="usage_total",
name="Total Usage",
translation_key="usage_total",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -158,9 +161,10 @@ async def async_setup_entry(
async_add_entities(
(
OpenEVSESensor(
config_entry.data[CONF_HOST],
config_entry.runtime_data,
description,
config_entry.entry_id,
config_entry.unique_id,
)
for description in SENSOR_TYPES
),
@@ -171,17 +175,32 @@ async def async_setup_entry(
class OpenEVSESensor(SensorEntity):
"""Implementation of an OpenEVSE sensor."""
_attr_has_entity_name = True
def __init__(
self,
host: str,
charger: OpenEVSE,
description: SensorEntityDescription,
entry_id: str,
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
self.entity_description = description
self.host = host
self.charger = charger
identifier = unique_id or entry_id
self._attr_unique_id = f"{identifier}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)},
manufacturer="OpenEVSE",
)
if unique_id:
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, unique_id)
}
self._attr_device_info[ATTR_SERIAL_NUMBER] = unique_id
async def async_update(self) -> None:
"""Get the monitored data from the charger."""
try:

View File

@@ -18,6 +18,31 @@
}
}
},
"entity": {
"sensor": {
"ambient_temp": {
"name": "Ambient temperature"
},
"charge_time": {
"name": "Charge time elapsed"
},
"ir_temp": {
"name": "IR temperature"
},
"rtc_temp": {
"name": "RTC temperature"
},
"status": {
"name": "Charging status"
},
"usage_session": {
"name": "Usage this session"
},
"usage_total": {
"name": "Total energy usage"
}
}
},
"issues": {
"yaml_deprecated": {
"description": "Configuring OpenEVSE using YAML is being removed. Your existing YAML configuration has been imported into the UI automatically. Remove the `openevse` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.",

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.0"]
"requirements": ["opower==0.16.1"]
}

View File

@@ -55,7 +55,7 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = {
key="optimization_mode",
translation_key="optimization_mode",
device_class=SensorDeviceClass.ENUM,
options=["off", "oso", "gridcompany", "smartcompany", "advanced"],
options=["off", "oso", "gridcompany", "smartcompany", "advanced", "nettleie"],
value_fn=lambda entity_data: entity_data.state.lower(),
),
"power_load": OSOEnergySensorEntityDescription(

View File

@@ -58,6 +58,7 @@
"state": {
"advanced": "Advanced",
"gridcompany": "Grid company",
"nettleie": "Grid fee",
"off": "[%key:common::state::off%]",
"oso": "OSO",
"smartcompany": "Smart company"

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.7.0"]
"requirements": ["python-otbr-api==2.7.1"]
}

View File

@@ -128,15 +128,15 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
states = self.device.states
if (
operating_mode := states[OverkizState.CORE_OPERATING_MODE]
) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL:
return PRESET_EXTERNAL
if (
state := states[OverkizState.IO_TARGET_HEATING_LEVEL]
) and state.value_as_str:
return OVERKIZ_TO_PRESET_MODE[state.value_as_str]
if (
operating_mode := states[OverkizState.CORE_OPERATING_MODE]
) and operating_mode.value_as_str == OverkizCommandParam.EXTERNAL:
return PRESET_EXTERNAL
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:

View File

@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.19.3"],
"requirements": ["pyoverkiz==1.19.4"],
"zeroconf": [
{
"name": "gateway*",

View File

@@ -2,8 +2,11 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from pyportainer import Portainer
@@ -30,23 +33,40 @@ from .coordinator import (
PortainerCoordinator,
PortainerCoordinatorData,
)
from .entity import PortainerContainerEntity
from .entity import PortainerContainerEntity, PortainerEndpointEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class PortainerButtonDescription(ButtonEntityDescription):
"""Class to describe a Portainer button entity."""
# Note to reviewer: I am keeping the third argument a str, in order to keep mypy happy :)
press_action: Callable[
[Portainer, int, str],
Coroutine[Any, Any, None],
]
BUTTONS: tuple[PortainerButtonDescription, ...] = (
ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = (
PortainerButtonDescription(
key="images_prune",
translation_key="images_prune",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=(
lambda portainer, endpoint_id, _: portainer.images_prune(
endpoint_id=endpoint_id, dangling=False, until=timedelta(days=0)
)
),
),
)
CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = (
PortainerButtonDescription(
key="restart",
name="Restart Container",
translation_key="restart_container",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=(
@@ -66,22 +86,43 @@ async def async_setup_entry(
"""Set up Portainer buttons."""
coordinator = entry.runtime_data
def _async_add_new_endpoints(endpoints: list[PortainerCoordinatorData]) -> None:
"""Add new endpoint binary sensors."""
async_add_entities(
PortainerEndpointButton(
coordinator,
entity_description,
endpoint,
)
for entity_description in ENDPOINT_BUTTONS
for endpoint in endpoints
)
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
) -> None:
"""Add new container button sensors."""
async_add_entities(
PortainerButton(
PortainerContainerButton(
coordinator,
entity_description,
container,
endpoint,
)
for (endpoint, container) in containers
for entity_description in BUTTONS
for entity_description in CONTAINER_BUTTONS
)
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
_async_add_new_endpoints(
[
endpoint
for endpoint in coordinator.data.values()
if endpoint.id in coordinator.known_endpoints
]
)
_async_add_new_containers(
[
(endpoint, container)
@@ -91,7 +132,62 @@ async def async_setup_entry(
)
class PortainerButton(PortainerContainerEntity, ButtonEntity):
class PortainerBaseButton(ButtonEntity):
"""Common base for Portainer buttons. Basically to ensure the async_press logic isn't duplicated."""
entity_description: PortainerButtonDescription
coordinator: PortainerCoordinator
@abstractmethod
async def _async_press_call(self) -> None:
"""Abstract method used per Portainer button class."""
async def async_press(self) -> None:
"""Trigger the Portainer button press service."""
try:
await self._async_press_call()
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton):
"""Defines a Portainer endpoint button."""
entity_description: PortainerButtonDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerButtonDescription,
device_info: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer endpoint button entity."""
self.entity_description = entity_description
super().__init__(device_info, coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Call the endpoint button press action."""
await self.entity_description.press_action(
self.coordinator.portainer, self.device_id, ""
)
class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton):
"""Defines a Portainer button."""
entity_description: PortainerButtonDescription
@@ -109,29 +205,10 @@ class PortainerButton(PortainerContainerEntity, ButtonEntity):
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
async def async_press(self) -> None:
"""Trigger the Portainer button press service."""
try:
await self.entity_description.press_action(
self.coordinator.portainer,
self.endpoint_id,
self.container_data.container.id,
)
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_press_call(self) -> None:
"""Call the container button press action."""
await self.entity_description.press_action(
self.coordinator.portainer,
self.endpoint_id,
self.container_data.container.id,
)

View File

@@ -61,6 +61,14 @@
"name": "Status"
}
},
"button": {
"images_prune": {
"name": "Prune unused images"
},
"restart_container": {
"name": "Restart container"
}
},
"sensor": {
"api_version": {
"name": "API version"
@@ -138,11 +146,20 @@
"cannot_connect": {
"message": "An error occurred while trying to connect to the Portainer instance: {error}"
},
"cannot_connect_no_details": {
"message": "An error occurred while trying to connect to the Portainer instance."
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"invalid_auth_no_details": {
"message": "An error occurred while trying to authenticate."
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Portainer instance: {error}"
},
"timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Portainer instance."
}
}
}

View File

@@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .coordinator import (
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -30,12 +34,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerfoxConfigEntry) ->
await client.close()
raise ConfigEntryNotReady from err
coordinators: list[PowerfoxDataUpdateCoordinator] = [
PowerfoxDataUpdateCoordinator(hass, entry, client, device)
for device in devices
# Filter out gas meter devices (Powerfox FLOW adapters) as they are not yet supported and cause integration failures
if device.type != DeviceType.GAS_METER
]
coordinators: list[
PowerfoxDataUpdateCoordinator | PowerfoxReportDataUpdateCoordinator
] = []
for device in devices:
if device.type == DeviceType.GAS_METER:
coordinators.append(
PowerfoxReportDataUpdateCoordinator(hass, entry, client, device)
)
continue
coordinators.append(PowerfoxDataUpdateCoordinator(hass, entry, client, device))
await asyncio.gather(
*[

View File

@@ -2,8 +2,11 @@
from __future__ import annotations
from datetime import datetime
from powerfox import (
Device,
DeviceReport,
Powerfox,
PowerfoxAuthenticationError,
PowerfoxConnectionError,
@@ -15,14 +18,18 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxDataUpdateCoordinator]]
type PowerfoxCoordinator = (
"PowerfoxDataUpdateCoordinator" | "PowerfoxReportDataUpdateCoordinator"
)
type PowerfoxConfigEntry = ConfigEntry[list[PowerfoxCoordinator]]
class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
"""Class to manage fetching Powerfox data from the API."""
class PowerfoxBaseCoordinator[T](DataUpdateCoordinator[T]):
"""Base coordinator handling shared Powerfox logic."""
config_entry: PowerfoxConfigEntry
@@ -33,7 +40,7 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
client: Powerfox,
device: Device,
) -> None:
"""Initialize global Powerfox data updater."""
"""Initialize shared Powerfox coordinator."""
super().__init__(
hass,
LOGGER,
@@ -44,11 +51,37 @@ class PowerfoxDataUpdateCoordinator(DataUpdateCoordinator[Poweropti]):
self.client = client
self.device = device
async def _async_update_data(self) -> Poweropti:
"""Fetch data from Powerfox API."""
async def _async_update_data(self) -> T:
"""Fetch data and normalize Powerfox errors."""
try:
return await self.client.device(device_id=self.device.id)
return await self._async_fetch_data()
except PowerfoxAuthenticationError as err:
raise ConfigEntryAuthFailed(err) from err
except (PowerfoxConnectionError, PowerfoxNoDataError) as err:
raise UpdateFailed(err) from err
async def _async_fetch_data(self) -> T:
"""Fetch data from the Powerfox API."""
raise NotImplementedError
class PowerfoxDataUpdateCoordinator(PowerfoxBaseCoordinator[Poweropti]):
"""Class to manage fetching Powerfox data from the API."""
async def _async_fetch_data(self) -> Poweropti:
"""Fetch live device data from the Powerfox API."""
return await self.client.device(device_id=self.device.id)
class PowerfoxReportDataUpdateCoordinator(PowerfoxBaseCoordinator[DeviceReport]):
"""Coordinator handling report data from the API."""
async def _async_fetch_data(self) -> DeviceReport:
"""Fetch report data from the Powerfox API."""
local_now = datetime.now(tz=dt_util.get_time_zone(self.hass.config.time_zone))
return await self.client.report(
device_id=self.device.id,
year=local_now.year,
month=local_now.month,
day=local_now.day,
)

View File

@@ -5,18 +5,18 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from powerfox import HeatMeter, PowerMeter, WaterMeter
from powerfox import DeviceReport, HeatMeter, PowerMeter, WaterMeter
from homeassistant.core import HomeAssistant
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .coordinator import PowerfoxConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PowerfoxConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Powerfox config entry."""
powerfox_data: list[PowerfoxDataUpdateCoordinator] = entry.runtime_data
powerfox_data = entry.runtime_data
return {
"devices": [
@@ -68,6 +68,21 @@ async def async_get_config_entry_diagnostics(
if isinstance(coordinator.data, HeatMeter)
else {}
),
**(
{
"gas_meter": {
"sum": coordinator.data.gas.sum,
"consumption": coordinator.data.gas.consumption,
"consumption_kwh": coordinator.data.gas.consumption_kwh,
"current_consumption": coordinator.data.gas.current_consumption,
"current_consumption_kwh": coordinator.data.gas.current_consumption_kwh,
"sum_currency": coordinator.data.gas.sum_currency,
}
}
if isinstance(coordinator.data, DeviceReport)
and coordinator.data.gas
else {}
),
}
for coordinator in powerfox_data
],

View File

@@ -2,23 +2,27 @@
from __future__ import annotations
from typing import Any
from powerfox import Device
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PowerfoxDataUpdateCoordinator
from .coordinator import PowerfoxBaseCoordinator
class PowerfoxEntity(CoordinatorEntity[PowerfoxDataUpdateCoordinator]):
class PowerfoxEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
CoordinatorEntity[CoordinatorT]
):
"""Base entity for Powerfox."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
coordinator: CoordinatorT,
device: Device,
) -> None:
"""Initialize Powerfox entity."""

View File

@@ -70,10 +70,7 @@ rules:
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration does not have any entities that should disabled by default.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:

View File

@@ -4,8 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from powerfox import Device, HeatMeter, PowerMeter, WaterMeter
from powerfox import Device, GasReport, HeatMeter, PowerMeter, WaterMeter
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -13,11 +14,16 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.const import CURRENCY_EURO, UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .coordinator import (
PowerfoxBaseCoordinator,
PowerfoxConfigEntry,
PowerfoxDataUpdateCoordinator,
PowerfoxReportDataUpdateCoordinator,
)
from .entity import PowerfoxEntity
@@ -30,6 +36,13 @@ class PowerfoxSensorEntityDescription[T: (PowerMeter, WaterMeter, HeatMeter)](
value_fn: Callable[[T], float | int | None]
@dataclass(frozen=True, kw_only=True)
class PowerfoxReportSensorEntityDescription(SensorEntityDescription):
"""Describes Powerfox report sensor entity."""
value_fn: Callable[[GasReport], float | int | None]
SENSORS_POWER: tuple[PowerfoxSensorEntityDescription[PowerMeter], ...] = (
PowerfoxSensorEntityDescription[PowerMeter](
key="power",
@@ -126,6 +139,104 @@ SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = (
),
)
SENSORS_GAS: tuple[PowerfoxReportSensorEntityDescription, ...] = (
PowerfoxReportSensorEntityDescription(
key="gas_consumption_today",
translation_key="gas_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda gas: gas.sum,
),
PowerfoxReportSensorEntityDescription(
key="gas_consumption_energy_today",
translation_key="gas_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_current_consumption",
translation_key="gas_current_consumption",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.current_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_current_consumption_energy",
translation_key="gas_current_consumption_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.current_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_cost_today",
translation_key="gas_cost_today",
native_unit_of_measurement=CURRENCY_EURO,
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
state_class=SensorStateClass.TOTAL,
value_fn=lambda gas: gas.sum_currency,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_consumption_today",
translation_key="gas_max_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.max_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_min_consumption_today",
translation_key="gas_min_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
value_fn=lambda gas: gas.min_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_avg_consumption_today",
translation_key="gas_avg_consumption_today",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.avg_consumption,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_consumption_energy_today",
translation_key="gas_max_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.max_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_min_consumption_energy_today",
translation_key="gas_min_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.min_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_avg_consumption_energy_today",
translation_key="gas_avg_consumption_energy_today",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=lambda gas: gas.avg_consumption_kwh,
),
PowerfoxReportSensorEntityDescription(
key="gas_max_cost_today",
translation_key="gas_max_cost_today",
native_unit_of_measurement=CURRENCY_EURO,
device_class=SensorDeviceClass.MONETARY,
suggested_display_precision=2,
value_fn=lambda gas: gas.max_currency,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -135,6 +246,20 @@ async def async_setup_entry(
"""Set up Powerfox sensors based on a config entry."""
entities: list[SensorEntity] = []
for coordinator in entry.runtime_data:
if isinstance(coordinator, PowerfoxReportDataUpdateCoordinator):
gas_report = coordinator.data.gas
if gas_report is None:
continue
entities.extend(
PowerfoxGasSensorEntity(
coordinator=coordinator,
description=description,
device=coordinator.device,
)
for description in SENSORS_GAS
if description.value_fn(gas_report) is not None
)
continue
if isinstance(coordinator.data, PowerMeter):
entities.extend(
PowerfoxSensorEntity(
@@ -166,23 +291,49 @@ async def async_setup_entry(
async_add_entities(entities)
class PowerfoxSensorEntity(PowerfoxEntity, SensorEntity):
"""Defines a powerfox power meter sensor."""
class BasePowerfoxSensorEntity[CoordinatorT: PowerfoxBaseCoordinator[Any]](
PowerfoxEntity[CoordinatorT], SensorEntity
):
"""Common base for Powerfox sensor entities."""
entity_description: PowerfoxSensorEntityDescription
entity_description: SensorEntityDescription
def __init__(
self,
coordinator: PowerfoxDataUpdateCoordinator,
coordinator: CoordinatorT,
device: Device,
description: PowerfoxSensorEntityDescription,
description: SensorEntityDescription,
) -> None:
"""Initialize Powerfox power meter sensor."""
"""Initialize the shared Powerfox sensor."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
class PowerfoxSensorEntity(BasePowerfoxSensorEntity[PowerfoxDataUpdateCoordinator]):
"""Defines a powerfox poweropti sensor."""
coordinator: PowerfoxDataUpdateCoordinator
entity_description: PowerfoxSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
return self.entity_description.value_fn(self.coordinator.data)
class PowerfoxGasSensorEntity(
BasePowerfoxSensorEntity[PowerfoxReportDataUpdateCoordinator]
):
"""Defines a powerfox gas meter sensor."""
coordinator: PowerfoxReportDataUpdateCoordinator
entity_description: PowerfoxReportSensorEntityDescription
@property
def native_value(self) -> float | int | None:
"""Return the state of the entity."""
gas_report = self.coordinator.data.gas
if TYPE_CHECKING:
assert gas_report is not None
return self.entity_description.value_fn(gas_report)

View File

@@ -62,6 +62,42 @@
"energy_usage_low_tariff": {
"name": "Energy usage low tariff"
},
"gas_avg_consumption_energy_today": {
"name": "Avg gas hourly energy - today"
},
"gas_avg_consumption_today": {
"name": "Avg gas hourly consumption - today"
},
"gas_consumption_energy_today": {
"name": "Gas consumption energy - today"
},
"gas_consumption_today": {
"name": "Gas consumption - today"
},
"gas_cost_today": {
"name": "Gas cost - today"
},
"gas_current_consumption": {
"name": "Gas consumption - this hour"
},
"gas_current_consumption_energy": {
"name": "Gas consumption energy - this hour"
},
"gas_max_consumption_energy_today": {
"name": "Max gas hourly energy - today"
},
"gas_max_consumption_today": {
"name": "Max gas hourly consumption - today"
},
"gas_max_cost_today": {
"name": "Max gas hourly cost - today"
},
"gas_min_consumption_energy_today": {
"name": "Min gas hourly energy - today"
},
"gas_min_consumption_today": {
"name": "Min gas hourly consumption - today"
},
"heat_delta_energy": {
"name": "Delta energy"
},

View File

@@ -16,5 +16,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.3.0"]
"requirements": ["ruuvitag-ble==0.4.0"]
}

View File

@@ -151,6 +151,12 @@ SENSOR_DESCRIPTIONS = {
translation_key="nox_index",
state_class=SensorStateClass.MEASUREMENT,
),
"iaqs": SensorEntityDescription(
key="iaqs",
translation_key="iaqs",
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
}

View File

@@ -33,6 +33,9 @@
"acceleration_z": {
"name": "Acceleration Z"
},
"iaqs": {
"name": "Indoor air quality score"
},
"movement_counter": {
"name": "Movement counter"
},

View File

@@ -15,6 +15,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,
]
@@ -25,11 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]
client = SaunumClient(host=host)
# Test connection
try:
await client.connect()
client = await SaunumClient.create(host)
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc

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