Compare commits

...

93 Commits

Author SHA1 Message Date
mib1185 047a9cd189 add data descriptions for all fields 2026-05-09 18:05:36 +00:00
Michael 9c9b626ade Use has_entity_name for all entities in FRITZ!SmartHome integration (#170199) 2026-05-09 19:01:51 +02:00
Klaas Schoute e0d3eb0fe3 Add config flow connection check to easyEnergy integration (#170207) 2026-05-09 17:18:27 +02:00
Øyvind Matheson Wergeland 5f5df558c6 Drop redundant HVAC mode validation in nobo_hub (#170140) 2026-05-09 16:28:46 +02:00
Klaas Schoute fbc5884ce8 Update easyEnergy integration to v3.0.1 (#170201) 2026-05-09 15:59:35 +02:00
Crocmagnon e72346c222 data grand lyon: exception translations (#170188) 2026-05-09 15:44:56 +02:00
Jan Bouwhuis 266f7b8dbe Fix overkiz snapshots (#170196) 2026-05-09 14:49:19 +02:00
Crocmagnon 3ae4811e99 data grand lyon: icon translations (#170189) 2026-05-09 14:28:15 +02:00
Crocmagnon 526ed271ae data grand lyon: mark repair-issues as exempt (#170194) 2026-05-09 14:26:32 +02:00
renovate[bot] 6c823cd970 Update infrared-protocols to 3.5.0 (#170169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 13:05:54 +02:00
Paul Bottein fb4b36b7f0 Add reconfigure flow to Novy Cooker Hood (#169410) 2026-05-09 10:27:27 +02:00
Paul Bottein 86898f9111 Add diagnostics to Novy Cooker Hood (#169891) 2026-05-09 08:38:58 +02:00
Abílio Costa 27969c34a5 Stop using make_command in LG Infrared (#170149) 2026-05-09 00:24:51 +03:00
Mick Vleeshouwer 74fabca890 Add button entity tests to Overkiz (#170122) 2026-05-09 00:24:37 +03:00
Crocmagnon af6fcae8b6 data grand lyon: update quality scale in manifest (#170109) 2026-05-08 23:04:47 +02:00
Thijs W. 818b420cb5 Update afsapi to 1.0.1 (#170073) 2026-05-08 23:02:43 +02:00
Øyvind Matheson Wergeland ef2a065784 Use suggested_display_precision in nobo_hub temperature sensor (#170138) 2026-05-08 21:03:25 +02:00
Mick Vleeshouwer 15943a737a Fix is_closed state for DynamicGate covers in Overkiz (#170130) 2026-05-08 20:42:50 +02:00
Heikki Henriksen 1647c0bf84 Bump pyprusalink to 2.2.0 (#170105)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:26:52 +02:00
bkobus-bbx 42aefd67dd Bump blebox_uniapi to v2.5.3 (#170115) 2026-05-08 16:21:54 +02:00
Mick Vleeshouwer c281c51fc9 Set is_closed state to None when a cover state returns "unknown" in Overkiz (#170081) 2026-05-08 16:20:10 +02:00
Robert Svensson fa09c6d29a Fix websocket certificate verification Bump axis to v70 (#170038) 2026-05-08 16:18:24 +02:00
Ronald van der Meer 9f7ddcca22 Add system health platform for Duco integration (#169517) 2026-05-08 13:25:28 +02:00
Willem-Jan van Rootselaar e488c7f3a5 Bump python-bsblan to 5.2.1 (#170100) 2026-05-08 13:17:47 +02:00
wollew bb924e79b1 Speed up Velux setup by avoiding disconnect from gateway (#167932)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 12:57:48 +02:00
Øyvind Matheson Wergeland 39d60faa42 Add DHCP discovery to nobo_hub (#169595)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:57:05 +02:00
Mick Vleeshouwer 378a26f778 Add number entity tests to Overkiz (#170098) 2026-05-08 12:23:07 +02:00
Mick Vleeshouwer 5c12d59ab7 Fix tilt support for UpDownVenetianBlind (rts:VenetianBlindRTSComponent) in Overkiz (#170047) 2026-05-08 12:22:23 +02:00
TheJulianJES c9e44d2d51 Bump ZHA to 1.3.1 (#170095) 2026-05-08 12:18:19 +02:00
Heikki Henriksen c195ddd8f2 prusalink: extract PrusaLinkEntityDescription base class (#170092)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:11:22 +02:00
Mick Vleeshouwer 4e388e1435 Fix cover controls for UpDownBioclimaticPergola in Overkiz (#170058) 2026-05-08 12:09:04 +02:00
Heikki Henriksen 191143d12d prusalink: expose printer location as suggested_area (#170099)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:08:09 +02:00
Mick Vleeshouwer bb6087cf87 Fix tilt controls for TiltOnlyVenetianBlind in Overkiz (#170055)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 11:57:37 +02:00
Rob Treacy 70e18fc196 Fix WiZ Light config flow timeout by properly closing UDP connections (#168456) 2026-05-08 10:35:13 +02:00
TheJulianJES 526ddc4770 Fix Z-Wave discovery crash with unknown node firmware version (#170090) 2026-05-08 10:21:39 +02:00
Mick Vleeshouwer 5f6bd9b6a7 Fix sensors getting wrong unit from MeasuredValueType attribute in Overkiz (#170088) 2026-05-08 10:15:20 +02:00
Maciej Bieniek 9b525bf1cb Use SensorDeviceClass.UPTIME in System Monitor (#170084) 2026-05-08 10:13:15 +02:00
Maciej Bieniek 3bc2c0d097 Use SensorDeviceClass.UPTIME in Unifi (#170087) 2026-05-08 10:12:57 +02:00
Mattie b5bdff7068 Add water_heater platform to Qube heat pump (#169851) 2026-05-08 10:12:26 +02:00
Erwin Douna 7103b07638 Portainer refactor tests to use enums from pyportainer (#170044) 2026-05-08 09:05:36 +02:00
Mick Vleeshouwer d52c281826 Fix is_closed state for DynamicGarageDoor in Overkiz (#170052) 2026-05-08 09:04:49 +02:00
Crocmagnon 9fca2f284b data grand lyon: implement reauth (#170059) 2026-05-08 09:04:16 +02:00
dependabot[bot] f1986d5fc3 Bump github/codeql-action from 4.35.2 to 4.35.3 (#170077) 2026-05-08 08:47:31 +02:00
Thomas Bouron ce9c83e33c Add fixture for Tuya pool heating pump (#170064) 2026-05-08 07:33:59 +02:00
renovate[bot] aa98fce92e Update infrared-protocols to 3.2.0 (#170070)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:11:27 +02:00
Robert Resch b01e56582a Bump deebot-client to 18.3.0 (#170066) 2026-05-08 01:43:54 +02:00
Mick Vleeshouwer 9be078475d Bump pyOverkiz to 1.20.3 (#170060) 2026-05-08 01:23:43 +02:00
Ronald van der Meer 9174ae4e00 Bump python-duco-client to 0.5.0 (#170065) 2026-05-08 01:10:50 +02:00
th3spis d4aa1b53f2 Added wfsens as a occupancy source in wiz (#166799)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 00:06:23 +02:00
mayerwin ba29f210c2 Translate switchbot_cloud library errors to HomeAssistantError (#169715)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-08 00:01:49 +02:00
Joost Lekkerkerker 845572927c Fix CI (#170061) 2026-05-07 22:51:44 +01:00
MoonDevLT 9cd7ac2722 Add sensor entity to lunatone integration (#167873)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-07 23:34:48 +02:00
Muhammad Ihsan a7fd763570 Add Cielo Home integration (#158511)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Owais Amin <141307092+owais-cielo@users.noreply.github.com>
Co-authored-by: Owais Amin <owais@cielowigle.com>
Co-authored-by: Maria Nadeem <maria@cielowigle.com>
2026-05-07 23:12:19 +02:00
theobld-ww 65491372c2 Bump visionpluspython 1.0.2 to 1.1.0 (#169842)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-07 22:41:16 +02:00
Mattie de96ee44e5 Add switch platform to Qube heat pump (#169407) 2026-05-07 22:40:37 +02:00
Crocmagnon 6edcf5722e Add Data Grand Lyon integration (#167946)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 22:12:57 +02:00
Jeef e6acebb322 Fix IntelliFire setup recovery (#169739) 2026-05-07 21:55:17 +02:00
Christian Lackas 277daf2dba vicare: migrate to OAuth2 with application credentials (#165621)
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-05-07 21:23:46 +02:00
Paulus Schoutsen 1b935314f8 Represent ThinQ hoods as fans instead of number entities (#159601)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-07 21:17:01 +02:00
G Johansson cad5c9e8fa Remove advanced mode from dnsip (#170040) 2026-05-07 21:03:15 +02:00
Midori Kochiya f7201f1910 Bump xiaomi-ble to 1.11.0 (#170018) 2026-05-07 19:34:12 +01:00
Glenn Waters c406e1aeed ElkM1 integration: Add time entity for settings (#170035) 2026-05-07 20:17:42 +02:00
G Johansson 946a3bcf11 Add missing areas in Nord Pool services (#169752) 2026-05-07 20:10:02 +02:00
Erwin Douna 2c8d9c7207 Add disk space coordinator for Portainer (#165855) 2026-05-07 20:05:29 +02:00
Michael db25f1911e Proper handling of malformed data during FRITZ!Box Tools setup (#170030) 2026-05-07 19:59:06 +02:00
Yevhenii Vaskivskyi 7e2fa90773 Remove Advanced mode from asuswrt (#170029) 2026-05-07 19:54:12 +02:00
Felipe Santos ef83ccc423 Allow selecting input source on SmartThings TVs (#160034)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-05-07 18:53:38 +01:00
Ronald van der Meer 046b48df43 Bump python-duco-client to 0.4.2 (#170027) 2026-05-07 19:51:54 +02:00
epenet 66cd719f85 Fix KeyError in hydrawise (#169853) 2026-05-07 18:50:29 +01:00
renovate[bot] b0c2e57649 Update infrared-protocols to 3.1.0 (#169968)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2026-05-07 18:26:33 +01:00
Glenn Waters cb92fa27ba Add number entity to ElkM1 integration (#169861) 2026-05-07 17:39:30 +02:00
Erik Montnemery c3f8f6f310 Use modern API in condition tests (#170002) 2026-05-07 17:33:00 +02:00
Tomasz Dylewski a82205fed7 Added PAJ GPS integration (#165070)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-07 17:04:19 +02:00
epenet 776fd69e39 Use SensorDeviceClass.ENUM in Tuya sensors (#169987) 2026-05-07 17:02:31 +02:00
Christian Lackas 2863b59be4 Bump homematicip to 2.11.0 (#170005) 2026-05-07 16:58:13 +02:00
epenet 676e9c7f29 Migrate Cast to use runtime_data (#168856)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:57:29 +02:00
Petro31 05c3c058d6 Remove legacy alarm control panel template entities (#169608)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 15:54:59 +02:00
Petro31 fd93f24208 Remove legacy binary sensor template entities (#169610) 2026-05-07 15:52:43 +02:00
Petro31 544b21f014 Remove legacy cover template entities (#169611) 2026-05-07 15:51:41 +02:00
Petro31 8d30abab9e Remove legacy fan template entities (#169613) 2026-05-07 15:51:08 +02:00
Petro31 ee19c11565 Remove legacy lock template entities (#169725) 2026-05-07 15:50:22 +02:00
Heikki Henriksen c26eb2374d prusalink: add X/Y axis, location, and min extrusion temp sensors (#169312)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 15:39:07 +02:00
Kamil Breguła 59bc46a9d2 Fix Tuya siren entity naming to avoid incorrect main entity assignment (#170008)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-07 15:24:37 +02:00
Petro31 ab668ac576 Remove legacy sensor template entities (#169728) 2026-05-07 15:22:22 +02:00
Petro31 c4836600c4 Remove legacy vacuum template entities (#169732) 2026-05-07 15:18:45 +02:00
Petro31 f4e0349825 Remove legacy light template entities (#169615) 2026-05-07 15:00:39 +02:00
Petro31 4d578b6c98 Remove legacy switch template entities (#169730) 2026-05-07 14:58:27 +02:00
chiro79 741779efd7 Remove name field from pvpc_hourly_pricing config flow #168955 (#169998) 2026-05-07 14:34:31 +02:00
Erik Montnemery eb1babedfd Improve condition docstrings (#170000)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 14:33:37 +02:00
Aidan Timson de0d24e91c Add default icon translations for lg_infrared (#170004) 2026-05-07 14:21:11 +02:00
Jan Bouwhuis 0de23f2636 Remove not used None defaults on MQTT publish API (#169936) 2026-05-07 13:53:29 +02:00
Ronald van der Meer ff69557b17 Bump python-duco-client to 0.4.1 (#169991) 2026-05-07 13:26:22 +02:00
G Johansson 3b93ccc7ba Fix double reloading in unifi (#155147)
Co-authored-by: Copilot <copilot@github.com>
2026-05-07 13:09:30 +02:00
307 changed files with 22484 additions and 2945 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
with:
category: "/language:python"
+2
View File
@@ -155,6 +155,7 @@ homeassistant.components.counter.*
homeassistant.components.cover.*
homeassistant.components.cpuspeed.*
homeassistant.components.crownstone.*
homeassistant.components.data_grand_lyon.*
homeassistant.components.date.*
homeassistant.components.datetime.*
homeassistant.components.deako.*
@@ -423,6 +424,7 @@ homeassistant.components.otp.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
homeassistant.components.paj_gps.*
homeassistant.components.panel_custom.*
homeassistant.components.paperless_ngx.*
homeassistant.components.peblar.*
Generated
+6
View File
@@ -294,6 +294,8 @@ CLAUDE.md @home-assistant/core
/tests/components/chacon_dio/ @cnico
/homeassistant/components/chess_com/ @joostlek
/tests/components/chess_com/ @joostlek
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo
/homeassistant/components/cisco_ios/ @fbradyirl
/homeassistant/components/cisco_mobility_express/ @fbradyirl
/homeassistant/components/cisco_webex_teams/ @fbradyirl
@@ -345,6 +347,8 @@ CLAUDE.md @home-assistant/core
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/data_grand_lyon/ @Crocmagnon
/tests/components/data_grand_lyon/ @Crocmagnon
/homeassistant/components/date/ @home-assistant/core
/tests/components/date/ @home-assistant/core
/homeassistant/components/datetime/ @home-assistant/core
@@ -1308,6 +1312,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas
/tests/components/p1_monitor/ @klaasnicolaas
/homeassistant/components/paj_gps/ @skipperro
/tests/components/paj_gps/ @skipperro
/homeassistant/components/palazzetti/ @dotvav
/tests/components/palazzetti/ @dotvav
/homeassistant/components/panel_custom/ @home-assistant/frontend
@@ -30,7 +30,6 @@ from homeassistant.helpers.schema_config_entry_flow import (
SchemaOptionsFlowHandler,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.typing import VolDictType
from .bridge import AsusWrtBridge
from .const import (
@@ -142,20 +141,12 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
user_input = self._config_data
add_schema: VolDictType
if self.show_advanced_options:
add_schema = {
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
vol.Optional(CONF_PORT): cv.port,
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
}
else:
add_schema = {vol.Required(CONF_PASSWORD): str}
schema = {
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str,
**add_schema,
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
vol.Optional(CONF_PORT): cv.port,
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
vol.Required(
CONF_PROTOCOL,
default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS),
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==70"],
"ssdp": [
{
"manufacturer": "AXIS"
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.2"],
"requirements": ["blebox-uniapi==2.5.3"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.2.0"],
"requirements": ["python-bsblan==5.2.1"],
"zeroconf": [
{
"name": "bsb-lan*",
+37 -19
View File
@@ -1,9 +1,12 @@
"""Component to embed Google Cast."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from dataclasses import dataclass, field
from typing import Protocol
from uuid import UUID
from pychromecast import Chromecast
from pychromecast.controllers.multizone import MultizoneManager
from pychromecast.discovery import CastBrowser
from homeassistant.components.media_player import BrowseMedia, MediaType
from homeassistant.config_entries import ConfigEntry
@@ -20,12 +23,41 @@ from .const import DOMAIN
PLATFORMS = [Platform.MEDIA_PLAYER]
type CastConfigEntry = ConfigEntry[CastRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class CastRuntimeData:
"""Runtime data for the Cast integration."""
cast_platforms: dict[str, CastProtocol] = field(default_factory=dict)
unknown_models: dict[str | None, tuple[str | None, str | None]] = field(
default_factory=dict
)
added_cast_devices: set[UUID] = field(default_factory=set)
browser: CastBrowser | None = None
multizone_manager: MultizoneManager | None = None
async def async_setup_entry(hass: HomeAssistant, entry: CastConfigEntry) -> bool:
"""Set up Cast from a config entry."""
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
entry.runtime_data = CastRuntimeData()
await home_assistant_cast.async_setup_ha_cast(hass, entry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
) -> None:
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
entry.runtime_data.cast_platforms[integration_domain] = platform
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
return True
@@ -65,27 +97,13 @@ class CastProtocol(Protocol):
"""
@callback
def _register_cast_platform(
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
):
"""Register a cast platform."""
if (
not hasattr(platform, "async_get_media_browser_root_object")
or not hasattr(platform, "async_browse_media")
or not hasattr(platform, "async_play_media")
):
raise HomeAssistantError(f"Invalid cast platform {platform}")
hass.data[DOMAIN]["cast_platform"][integration_domain] = platform
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
await home_assistant_cast.async_remove_user(hass, entry)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove cast config entry from a device.
+6 -8
View File
@@ -1,16 +1,11 @@
"""Config flow for Cast."""
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
@@ -19,6 +14,9 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
@@ -40,7 +38,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
) -> CastOptionsFlowHandler:
"""Get the options flow for this handler."""
return CastOptionsFlowHandler()
-7
View File
@@ -12,13 +12,6 @@ DOMAIN = "cast"
# Stores a threading.Lock that is held by the internal pychromecast discovery.
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Store a CastBrowser
CAST_BROWSER_KEY = "cast_browser"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
+19 -11
View File
@@ -2,17 +2,16 @@
import logging
import threading
from typing import TYPE_CHECKING
import pychromecast.discovery
import pychromecast.models
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
CAST_BROWSER_KEY,
CONF_KNOWN_HOSTS,
INTERNAL_DISCOVERY_RUNNING_KEY,
SIGNAL_CAST_DISCOVERED,
@@ -20,11 +19,16 @@ from .const import (
)
from .helpers import ChromecastInfo, ChromeCastZeroconf
if TYPE_CHECKING:
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
def discover_chromecast(
hass: HomeAssistant, cast_info: pychromecast.models.CastInfo
hass: HomeAssistant,
cast_info: pychromecast.models.CastInfo,
config_entry: CastConfigEntry,
) -> None:
"""Discover a Chromecast."""
@@ -36,7 +40,7 @@ def discover_chromecast(
_LOGGER.error("Discovered chromecast without uuid %s", info)
return
info = info.fill_out_missing_chromecast_info(hass)
info = info.fill_out_missing_chromecast_info(hass, config_entry)
_LOGGER.debug("Discovered new or updated chromecast %s", info)
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
@@ -49,7 +53,9 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def setup_internal_discovery(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
@@ -63,11 +69,11 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
def add_cast(self, uuid, _):
"""Handle zeroconf discovery of a new chromecast."""
discover_chromecast(hass, browser.devices[uuid])
discover_chromecast(hass, browser.devices[uuid], config_entry)
def update_cast(self, uuid, _):
"""Handle zeroconf discovery of an updated chromecast."""
discover_chromecast(hass, browser.devices[uuid])
discover_chromecast(hass, browser.devices[uuid], config_entry)
def remove_cast(self, uuid, service, cast_info):
"""Handle zeroconf discovery of a removed chromecast."""
@@ -84,7 +90,7 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
ChromeCastZeroconf.get_zeroconf(),
config_entry.data.get(CONF_KNOWN_HOSTS),
)
hass.data[CAST_BROWSER_KEY] = browser
config_entry.runtime_data.browser = browser
browser.start_discovery()
def stop_discovery(event):
@@ -98,7 +104,9 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry.add_update_listener(config_entry_updated)
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
async def config_entry_updated(
hass: HomeAssistant, config_entry: CastConfigEntry
) -> None:
"""Handle config entry being updated."""
browser = hass.data[CAST_BROWSER_KEY]
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
if browser := config_entry.runtime_data.browser:
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
+6 -6
View File
@@ -20,11 +20,11 @@ import pychromecast.socket_client
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
if TYPE_CHECKING:
from homeassistant.components import zeroconf
from . import CastConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -56,16 +56,16 @@ class ChromecastInfo:
"""Return the UUID."""
return self.cast_info.uuid
def fill_out_missing_chromecast_info(self, hass: HomeAssistant) -> ChromecastInfo:
def fill_out_missing_chromecast_info(
self, hass: HomeAssistant, config_entry: CastConfigEntry
) -> ChromecastInfo:
"""Return a new ChromecastInfo object with missing attributes filled in.
Uses blocking HTTP / HTTPS.
"""
cast_info = self.cast_info
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
unknown_models = hass.data[DOMAIN]["unknown_models"]
unknown_models = config_entry.runtime_data.unknown_models
if self.cast_info.model_name not in unknown_models:
# Manufacturer and cast type is not available in mDNS data,
# get it over HTTP
@@ -1,8 +1,10 @@
"""Home Assistant Cast integration for Cast."""
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant import auth, config_entries, core
from homeassistant import auth, core
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
@@ -11,6 +13,9 @@ from homeassistant.helpers.service import async_register_admin_service
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
if TYPE_CHECKING:
from . import CastConfigEntry
SERVICE_SHOW_VIEW = "show_lovelace_view"
ATTR_VIEW_PATH = "view_path"
ATTR_URL_PATH = "dashboard_path"
@@ -21,9 +26,7 @@ NO_URL_AVAILABLE_ERROR = (
)
async def async_setup_ha_cast(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Set up Home Assistant Cast."""
user_id: str | None = entry.data.get("user_id")
user: auth.models.User | None = None
@@ -87,9 +90,7 @@ async def async_setup_ha_cast(
)
async def async_remove_user(
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
):
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
"""Remove Home Assistant Cast user."""
user_id: str | None = entry.data.get("user_id")
+34 -25
View File
@@ -1,5 +1,4 @@
"""Provide functionality to interact with Cast devices on the network."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from collections.abc import Callable
from contextlib import suppress
@@ -42,7 +41,6 @@ from homeassistant.components.media_player import (
MediaType,
async_process_play_media_url,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
CONF_UUID,
@@ -58,8 +56,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from .const import (
ADDED_CAST_DEVICES_KEY,
CAST_MULTIZONE_MANAGER_KEY,
CONF_IGNORE_CEC,
DOMAIN,
SIGNAL_CAST_DISCOVERED,
@@ -78,7 +74,7 @@ from .helpers import (
)
if TYPE_CHECKING:
from . import CastProtocol
from . import CastConfigEntry, CastProtocol
_LOGGER = logging.getLogger(__name__)
@@ -110,7 +106,9 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
@callback
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
def _async_create_cast_device(
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
):
"""Create a CastDevice entity or dynamic group from the chromecast object.
Returns None if the cast device has already been added.
@@ -121,7 +119,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
return None
# Found a cast with UUID
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
added_casts = config_entry.runtime_data.added_cast_devices
if info.uuid in added_casts:
# Already added this one, the entity will take care of moved hosts
# itself
@@ -131,21 +129,19 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
if info.is_dynamic_group:
# This is a dynamic group, do not add it but connect to the service.
group = DynamicCastGroup(hass, info)
group = DynamicCastGroup(hass, config_entry, info)
group.async_setup()
return None
return CastMediaPlayerEntity(hass, info)
return CastMediaPlayerEntity(hass, config_entry, info)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: CastConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cast from a config entry."""
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
@@ -160,7 +156,7 @@ async def async_setup_entry(
# UUID not matching, ignore.
return
cast_device = _async_create_cast_device(hass, discover)
cast_device = _async_create_cast_device(hass, config_entry, discover)
if cast_device is not None:
async_add_entities([cast_device])
@@ -179,13 +175,19 @@ class CastDevice:
_mz_only: bool
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
self.hass: HomeAssistant = hass
self._config_entry = config_entry
self._cast_info = cast_info
self._chromecast: pychromecast.Chromecast | None = None
self.mz_mgr = None
self.mz_mgr: MultizoneManager | None = None
self._status_listener: CastStatusListener | None = None
self._add_remove_handler: Callable[[], None] | None = None
self._del_remove_handler: Callable[[], None] | None = None
@@ -214,7 +216,9 @@ class CastDevice:
if self._cast_info.uuid is not None:
# Remove the entity from the added casts so that it can dynamically
# be re-added again.
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
self._config_entry.runtime_data.added_cast_devices.remove(
self._cast_info.uuid
)
if self._add_remove_handler:
self._add_remove_handler()
self._add_remove_handler = None
@@ -237,10 +241,10 @@ class CastDevice:
)
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
runtime_data = self._config_entry.runtime_data
if runtime_data.multizone_manager is None:
runtime_data.multizone_manager = MultizoneManager()
self.mz_mgr = runtime_data.multizone_manager
self._status_listener = CastStatusListener(
self, chromecast, self.mz_mgr, self._mz_only
@@ -300,10 +304,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
_attr_media_image_remotely_accessible = True
_mz_only = False
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: CastConfigEntry,
cast_info: ChromecastInfo,
) -> None:
"""Initialize the cast device."""
CastDevice.__init__(self, hass, cast_info)
CastDevice.__init__(self, hass, config_entry, cast_info)
self.cast_status = None
self.media_status = None
@@ -592,7 +601,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
"""Generate root node."""
children = []
# Add media browsers
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
children.extend(
await platform.async_get_media_browser_root_object(
self.hass, self._chromecast.cast_type
@@ -651,7 +660,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
platform: CastProtocol
assert media_content_type is not None
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
browse_media = await platform.async_browse_media(
self.hass,
media_content_type,
@@ -713,7 +722,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
return
# Try the cast platforms
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
for platform in self._config_entry.runtime_data.cast_platforms.values():
result = await platform.async_play_media(
self.hass, self.entity_id, chromecast, media_type, media_id
)
@@ -0,0 +1,24 @@
"""Integration for Cielo Home."""
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
async def async_setup_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool:
"""Set up Cielo Home from a config entry."""
coordinator = CieloDataUpdateCoordinator(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: CieloHomeConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = entry.runtime_data
await coordinator.async_shutdown()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,311 @@
"""Support for Cielo home thermostats and Smart AC Controllers."""
import asyncio
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate, ParamSpec, TypeVar
from cieloconnectapi.exceptions import AuthenticationError
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CIELO_ERRORS, LOGGER, TIMEOUT
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
from .entity import CieloDeviceEntity
_T = TypeVar("_T", bound="CieloDeviceEntity")
_P = ParamSpec("_P")
PARALLEL_UPDATES = 0
CIELO_TO_HA_HVAC: dict[str, HVACMode] = {
"cool": HVACMode.COOL,
"heat": HVACMode.HEAT,
"fan": HVACMode.FAN_ONLY,
"dry": HVACMode.DRY,
"auto": HVACMode.AUTO,
"heat_cool": HVACMode.HEAT_COOL,
"off": HVACMode.OFF,
}
HA_TO_CIELO_HVAC: dict[HVACMode, str] = {v: k for k, v in CIELO_TO_HA_HVAC.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: CieloHomeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Cielo climate platform."""
coordinator = entry.runtime_data
devices = coordinator.data.parsed
async_add_entities([CieloClimate(coordinator, dev_id) for dev_id in devices])
def async_handle_api_call(
function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]:
"""Decorate api calls to handle exceptions and update state."""
async def wrap_api_call(*args: Any, **kwargs: Any) -> None:
"""Wrap services for api calls."""
entity: _T = args[0]
res: Any = None
try:
async with asyncio.timeout(TIMEOUT):
res = await function(*args, **kwargs)
except AuthenticationError as err:
raise ConfigEntryAuthFailed from err
except CIELO_ERRORS as err:
if isinstance(err, TimeoutError):
raise HomeAssistantError("API call timed out") from err
raise HomeAssistantError("Unable to perform API call") from err
LOGGER.debug(
"API call result for entity %s: type=%s keys=%s",
entity.entity_id,
type(res),
list(res.keys()) if isinstance(res, dict) else None,
)
if not isinstance(res, dict):
LOGGER.error(
"API function did not return a dictionary for entity %s, got %s",
entity.entity_id,
type(res),
)
raise HomeAssistantError("Invalid API response format")
data: dict[str, Any] | None = res.get("data")
if not data:
raise HomeAssistantError("API response contained no data payload")
await entity.coordinator.async_apply_action_result(entity.device_id, data)
return wrap_api_call
class CieloClimate(CieloDeviceEntity, ClimateEntity):
"""Representation of a Cielo Smart AC Controller."""
_attr_name = None
_attr_translation_key = "climate_device"
def __init__(self, coordinator: CieloDataUpdateCoordinator, device_id: str) -> None:
"""Initialize the climate device."""
super().__init__(coordinator, device_id)
self._attr_unique_id = device_id
@property
def temperature_unit(self) -> str:
"""Return the unit of temperature in Home Assistant format.
It can change over time based on the device settings, so we fetch it dynamically from the client.
"""
unit = self.client.temperature_unit()
if not unit:
return UnitOfTemperature.CELSIUS
normalized = unit.strip().lower()
if normalized in {"c", "°c", "celsius"}:
return UnitOfTemperature.CELSIUS
if normalized in {"f", "°f", "fahrenheit"}:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def supported_features(self) -> ClimateEntityFeature:
"""Return dynamic feature flags based on the current mode."""
flags = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
if self.hvac_mode == HVACMode.HEAT_COOL:
flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
elif self.client.mode_supports_temperature():
flags |= ClimateEntityFeature.TARGET_TEMPERATURE
caps = self.client.mode_caps()
if caps.get("fan_levels"):
flags |= ClimateEntityFeature.FAN_MODE
if caps.get("swing"):
flags |= ClimateEntityFeature.SWING_MODE
if self.device_data and self.device_data.preset_modes:
flags |= ClimateEntityFeature.PRESET_MODE
return flags
@property
def current_humidity(self) -> int | None:
"""Return the current humidity, if available."""
if self.device_data:
return self.device_data.humidity
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the low target temperature for HEAT_COOL mode."""
return self.client.target_temperature_low(self.temperature_unit)
@property
def target_temperature_high(self) -> float | None:
"""Return the high target temperature for HEAT_COOL mode."""
return self.client.target_temperature_high(self.temperature_unit)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
mode = self.client.hvac_mode()
return CIELO_TO_HA_HVAC.get(mode, mode)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
modes = self.client.hvac_modes() or []
return [CIELO_TO_HA_HVAC.get(m, m) for m in modes]
@property
def current_temperature(self) -> float | None:
"""Return the current indoor temperature."""
return self.client.current_temperature()
@property
def target_temperature(self) -> float | None:
"""Return the target temperature."""
return self.client.target_temperature()
@property
def min_temp(self) -> float:
"""Return the minimum possible target temperature."""
return self.client.min_temp()
@property
def max_temp(self) -> float:
"""Return the maximum possible target temperature."""
return self.client.max_temp()
@property
def target_temperature_step(self) -> float | None:
"""Return the precision of the thermostat."""
return self.client.target_temperature_step(self.temperature_unit)
@property
def fan_mode(self) -> str | None:
"""Return the current fan mode."""
return self.client.fan_mode()
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Fan modes are normalized in the backend to snake_case values that
match Home Assistant expectations (e.g. "low", "medium", "high", "auto").
This allows HA to translate and display icons correctly using the
integration strings definitions.
"""
return self.client.fan_modes()
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Swing modes are normalized in the backend to snake_case values
compatible with Home Assistant (e.g. "auto", "swing").
These values align with the integration translations so HA can display
proper labels and icons.
"""
return self.client.swing_modes()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self.client.preset_mode()
@property
def preset_modes(self) -> list[str] | None:
"""Return the list of available preset modes.
Preset modes are normalized in the backend to snake_case values that
match Home Assistant expectations (e.g. "home", "away", "sleep", "pets").
This allows HA to translate and display icons correctly using the
integration strings definitions.
"""
return self.client.preset_modes()
@property
def swing_mode(self) -> str | None:
"""Return the current swing mode."""
return self.device_data.swing_mode if self.device_data else None
@property
def precision(self) -> float:
"""Return the precision of the thermostat."""
return self.client.precision(self.temperature_unit)
@async_handle_api_call
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if self.hvac_mode == HVACMode.HEAT_COOL:
return await self.client.async_set_temperature(
self.temperature_unit,
**{
ATTR_TARGET_TEMP_LOW: kwargs.get(ATTR_TARGET_TEMP_LOW),
ATTR_TARGET_TEMP_HIGH: kwargs.get(ATTR_TARGET_TEMP_HIGH),
},
)
return await self.client.async_set_temperature(
self.temperature_unit,
**{ATTR_TEMPERATURE: kwargs.get(ATTR_TEMPERATURE)},
)
@async_handle_api_call
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new fan mode."""
return await self.client.async_set_fan_mode(fan_mode)
@async_handle_api_call
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
return await self.client.async_set_preset_mode(preset_mode)
@async_handle_api_call
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
cielo_mode = HA_TO_CIELO_HVAC.get(hvac_mode)
return await self.client.async_set_hvac_mode(cielo_mode)
@async_handle_api_call
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode."""
return await self.client.async_set_swing_mode(swing_mode)
async def async_turn_on(self) -> None:
"""Turn the climate device on."""
modes = self.hvac_modes or []
# Select the first supported non-off mode when turning on
for mode in modes:
if mode != HVACMode.OFF:
await self.async_set_hvac_mode(mode)
return
raise HomeAssistantError("No non-off HVAC modes available to turn on device")
async def async_turn_off(self) -> None:
"""Turn the climate device off."""
await self.async_set_hvac_mode(HVACMode.OFF)
@@ -0,0 +1,99 @@
"""Config Flow for Cielo integration."""
from typing import Any, Final
from aiohttp import ClientError
from cieloconnectapi import CieloClient
from cieloconnectapi.exceptions import AuthenticationError, CieloError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_NAME, DOMAIN, LOGGER, TIMEOUT
DATA_SCHEMA: Final = vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class CieloConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cielo integration."""
VERSION = 1
MINOR_VERSION = 1
async def _async_validate_api_key(
self, api_key: str
) -> tuple[str | None, dict[str, str]]:
"""Validate the API key, initialize the client, and return errors or token."""
client = CieloClient(
api_key=api_key,
timeout=TIMEOUT,
session=async_get_clientsession(self.hass),
)
try:
token = await client.get_or_refresh_token()
devices = await client.get_devices_data()
if not devices.parsed:
return None, {"base": "no_devices"}
except AuthenticationError:
return None, {"base": "invalid_auth"}
except ConnectionError, TimeoutError, ClientError, CieloError:
return None, {"base": "cannot_connect"}
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception during config flow validation")
return None, {"base": "unknown"}
return client.user_id, {CONF_TOKEN: token}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input:
api_key = user_input[CONF_API_KEY].strip()
user_id, validation_result = await self._async_validate_api_key(api_key)
if "base" in validation_result:
errors = validation_result
else:
token: str = validation_result[CONF_TOKEN]
user_input[CONF_API_KEY] = api_key
user_input[CONF_TOKEN] = token
await self.async_set_unique_id(user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=DEFAULT_NAME,
data=user_input,
)
# Show the user form
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
"url": "https://www.home-assistant.io/integrations/cielo_home"
},
)
@@ -0,0 +1,24 @@
"""Constants for the Cielo Home integration."""
import logging
from typing import Final
from aiohttp import ClientError
from cieloconnectapi.exceptions import CieloError
from homeassistant.const import Platform
DOMAIN: Final = "cielo_home"
PLATFORMS: Final[list[Platform]] = [
Platform.CLIMATE,
]
DEFAULT_NAME: Final = "Cielo Home"
DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60
TIMEOUT: Final[int] = 20
LOGGER: Final = logging.getLogger(__package__)
CIELO_ERRORS: Final[tuple] = (
ClientError,
TimeoutError,
CieloError,
)
@@ -0,0 +1,107 @@
"""Coordinator for Cielo integration."""
from copy import copy
from dataclasses import dataclass
from datetime import timedelta
from typing import Any, Final
from aiohttp import ClientError
from cieloconnectapi import CieloClient
from cieloconnectapi.exceptions import AuthenticationError, CieloError
from cieloconnectapi.model import CieloDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
REQUEST_REFRESH_DELAY: Final[int] = 2 * 60
@dataclass(slots=True)
class CieloData:
"""Data structure for the coordinator."""
raw: dict[str, Any]
parsed: dict[str, CieloDevice]
class CieloDataUpdateCoordinator(DataUpdateCoordinator[CieloData]):
"""Cielo Data Update Coordinator."""
config_entry: CieloHomeConfigEntry
def __init__(self, hass: HomeAssistant, entry: CieloHomeConfigEntry) -> None:
"""Initialize the coordinator."""
self.client = CieloClient(
api_key=entry.data[CONF_API_KEY],
timeout=TIMEOUT,
token=entry.data[CONF_TOKEN],
session=async_get_clientsession(hass),
)
super().__init__(
hass,
LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
# The debouncer prevents multiple rapid refresh requests from triggering repeated full data fetches from the backend.
request_refresh_debouncer=Debouncer(
hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
async def _async_update_data(self) -> CieloData:
"""Fetch data from the API."""
try:
data = await self.client.get_devices_data()
except AuthenticationError as err:
raise ConfigEntryAuthFailed from err
except (TimeoutError, ConnectionError, CieloError, ClientError) as err:
raise UpdateFailed(err) from err
return CieloData(raw=data.raw, parsed=data.parsed)
async def async_apply_action_result(
self, device_id: str, data: dict[str, Any]
) -> None:
"""Apply an optimistic update from an API action response.
This updates the affected device locally in the coordinator state so the
UI reflects the change immediately without requiring a full backend refresh.
Performing a coordinator refresh after every action would fetch all devices
for the account, even when only a single device was updated. This is not
optimal from an API usage/cost perspective.
Instead, the coordinator applies the action result locally for the affected
device and schedules a later refresh to reconcile with the backend state.
"""
if not self.data or not self.data.parsed or device_id not in self.data.parsed:
await self.async_request_refresh()
return
new_parsed = dict(self.data.parsed)
dev = copy(new_parsed[device_id])
try:
dev.apply_update(data)
except KeyError, ValueError, TypeError:
await self.async_request_refresh()
return
new_parsed[device_id] = dev
self.async_set_updated_data(CieloData(raw=self.data.raw, parsed=new_parsed))
# Request a debounced refresh to reconcile with the backend state.
await self.async_request_refresh()
# Define the ConfigEntry type here to avoid circular imports
type CieloHomeConfigEntry = ConfigEntry[CieloDataUpdateCoordinator]
@@ -0,0 +1,76 @@
"""Base entity for Cielo integration."""
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import CieloDataUpdateCoordinator
class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]):
"""Representation of a Cielo base entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CieloDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize the Cielo base entity."""
super().__init__(coordinator)
self._device_id = device_id
self.client = CieloDeviceAPI(
coordinator.client, coordinator.data.parsed[device_id]
)
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (dev := self.device_data) is not None:
self.client.device_data = dev
super()._handle_coordinator_update()
@property
def device_data(self) -> CieloDevice | None:
"""Return the device data from the coordinator."""
return self.coordinator.data.parsed.get(self._device_id)
@property
def available(self) -> bool:
"""Return if the device is available and online."""
if not (super().available and self._device_id in self.coordinator.data.parsed):
return False
dev = self.device_data
return bool(dev and dev.device_status)
class CieloDeviceEntity(CieloBaseEntity):
"""Representation of a Cielo Device."""
def __init__(
self,
coordinator: CieloDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize the device entity."""
super().__init__(coordinator, device_id)
self.device_id = device_id
device = coordinator.data.parsed[device_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
manufacturer="Cielo",
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
)
@@ -0,0 +1,12 @@
{
"domain": "cielo_home",
"name": "Cielo Home",
"codeowners": ["@ihsan-cielo", "@mudasar-cielo"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cielo_home",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["cieloconnectapi"],
"quality_scale": "bronze",
"requirements": ["cielo-connect-api==1.0.6"]
}
@@ -0,0 +1,60 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
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: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
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: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -0,0 +1,69 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "Invalid or expired API key; generate a new one",
"no_devices": "No devices found; make sure devices are set up in the Cielo Home app",
"no_user_id": "No valid user information found for the API key",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key from your Cielo Home account"
},
"description": "Sign in with your Cielo Home API key. Follow the [documentation]({url}) to learn how to get your API key.",
"title": "Connect to Cielo Home"
}
}
},
"entity": {
"climate": {
"climate_device": {
"state_attributes": {
"fan_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"quiet": "Quiet",
"super_high": "Super high",
"ultra_high": "Ultra high"
}
},
"swing_mode": {
"state": {
"adjust": "Adjust",
"auto": "[%key:common::state::auto%]",
"auto_stop": "Auto Stop",
"pos1": "Position 1",
"pos10": "Position 10",
"pos11": "Position 11",
"pos12": "Position 12",
"pos13": "Position 13",
"pos14": "Position 14",
"pos15": "Position 15",
"pos2": "Position 2",
"pos3": "Position 3",
"pos4": "Position 4",
"pos5": "Position 5",
"pos6": "Position 6",
"pos7": "Position 7",
"pos8": "Position 8",
"pos9": "Position 9",
"swing": "Swing"
}
}
}
}
}
}
}
@@ -0,0 +1,48 @@
"""The Data Grand Lyon integration."""
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> bool:
"""Set up Data Grand Lyon from a config entry."""
session = async_get_clientsession(hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
coordinator = DataGrandLyonCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> None:
"""Handle config entry update (e.g., subentry changes)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,163 @@
"""Config flow for the Data Grand Lyon integration."""
from collections.abc import Mapping
import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclPassageType
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, SUBENTRY_TYPE_STOP
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_STOP_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_LINE): str,
vol.Required(CONF_STOP_ID): vol.Coerce(int),
}
)
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Data Grand Lyon."""
VERSION = 1
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentry types supported by this integration."""
return {
SUBENTRY_TYPE_STOP: StopSubentryFlowHandler,
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
if error := await self._test_connection(user_input):
errors["base"] = error
else:
return self.async_create_entry(title="Data Grand Lyon", data=user_input)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication with new credentials."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
if error := await self._test_connection(user_input):
errors["base"] = error
else:
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
),
errors=errors,
)
async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
"""Test connectivity by making a dummy API call.
Returns None on success, or an error key for the errors dict.
"""
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
# the upstream library filters in memory so these placeholder values
# won't trigger an exception ; the returned list will be empty
await client.get_tcl_passages(
ligne="__test__", stop_id=0, passage_type=TclPassageType.ESTIMATED
)
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error testing Data Grand Lyon connection")
return "unknown"
return None
class StopSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the user step to add a new stop."""
entry = self._get_entry()
if user_input is not None:
line = user_input[CONF_LINE]
stop_id = user_input[CONF_STOP_ID]
unique_id = f"{line}_{stop_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
name = f"{line} - Stop {stop_id}"
return self.async_create_entry(
title=name,
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
unique_id=unique_id,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_STOP_DATA_SCHEMA,
)
@@ -0,0 +1,11 @@
"""Constants for the Data Grand Lyon integration."""
import logging
DOMAIN = "data_grand_lyon"
LOGGER = logging.getLogger(__package__)
SUBENTRY_TYPE_STOP = "stop"
CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
@@ -0,0 +1,83 @@
"""DataUpdateCoordinator for the Data Grand Lyon integration."""
import asyncio
from datetime import timedelta
from aiohttp import ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclPassage
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 .const import CONF_LINE, CONF_STOP_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_STOP
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
class DataGrandLyonCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
"""Coordinator for the Data Grand Lyon integration."""
config_entry: DataGrandLyonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
client: DataGrandLyonClient,
) -> None:
"""Initialize the coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
"""Fetch data for all monitored stops."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
stop_tasks = [
self.client.get_tcl_passages(
ligne=subentry.data[CONF_LINE],
stop_id=subentry.data[CONF_STOP_ID],
)
for subentry in stop_subentries
]
stop_results: list[list[TclPassage] | BaseException] = await asyncio.gather(
*stop_tasks, return_exceptions=True
)
stops: dict[str, list[TclPassage]] = {}
for i, subentry in enumerate(stop_subentries):
result = stop_results[i]
if isinstance(result, BaseException):
if isinstance(result, ClientResponseError) and result.status in (
401,
403,
):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from result
LOGGER.warning(
"Error fetching departures for stop %s: %s",
subentry.subentry_id,
result,
)
continue
stops[subentry.subentry_id] = result
if stop_subentries and not stops:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_all_stops",
)
return stops
@@ -0,0 +1,42 @@
{
"entity": {
"sensor": {
"next_departure_1": {
"default": "mdi:bus-clock"
},
"next_departure_1_direction": {
"default": "mdi:directions"
},
"next_departure_1_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
},
"next_departure_2": {
"default": "mdi:bus-clock"
},
"next_departure_2_direction": {
"default": "mdi:directions"
},
"next_departure_2_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
},
"next_departure_3": {
"default": "mdi:bus-clock"
},
"next_departure_3_direction": {
"default": "mdi:directions"
},
"next_departure_3_type": {
"default": "mdi:clock-outline",
"state": {
"estimated": "mdi:clock-check-outline"
}
}
}
}
}
@@ -0,0 +1,11 @@
{
"domain": "data_grand_lyon",
"name": "Data Grand Lyon",
"codeowners": ["@Crocmagnon"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["data-grand-lyon-ha==0.5.0"]
}
@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This 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: This 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 use the coordinator pattern and do not 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 register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This is a service integration; there are no discoverable devices.
discovery:
status: exempt
comment: This is a service integration; there are no discoverable devices.
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -0,0 +1,180 @@
"""Sensor platform for the Data Grand Lyon integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from zoneinfo import ZoneInfo
from data_grand_lyon_ha import TclPassage, TclPassageType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SUBENTRY_TYPE_STOP
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
PARALLEL_UPDATES = 0
_TZ_PARIS = ZoneInfo("Europe/Paris")
_DEPARTURE_TYPE_OPTIONS = [t.name.lower() for t in TclPassageType]
def _departure_time(departure: TclPassage) -> datetime:
"""Return the departure time, localized to Europe/Paris if naive."""
dt = departure.heure_passage
if dt.tzinfo is None:
return dt.replace(tzinfo=_TZ_PARIS)
return dt
@dataclass(frozen=True, kw_only=True)
class DataGrandLyonStopSensorEntityDescription(SensorEntityDescription):
"""Describes a Data Grand Lyon stop departure sensor entity."""
departure_index: int
value_fn: Callable[[TclPassage], StateType | datetime]
STOP_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonStopSensorEntityDescription, ...] = (
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1",
translation_key="next_departure_1",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=0,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1_direction",
translation_key="next_departure_1_direction",
departure_index=0,
value_fn=lambda p: p.direction,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_1_type",
translation_key="next_departure_1_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=0,
value_fn=lambda p: p.type.name.lower(),
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2",
translation_key="next_departure_2",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=1,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2_direction",
translation_key="next_departure_2_direction",
departure_index=1,
value_fn=lambda p: p.direction,
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_2_type",
translation_key="next_departure_2_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=1,
value_fn=lambda p: p.type.name.lower(),
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3",
translation_key="next_departure_3",
device_class=SensorDeviceClass.TIMESTAMP,
departure_index=2,
value_fn=_departure_time,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3_direction",
translation_key="next_departure_3_direction",
departure_index=2,
value_fn=lambda p: p.direction,
entity_registry_enabled_default=False,
),
DataGrandLyonStopSensorEntityDescription(
key="next_departure_3_type",
translation_key="next_departure_3_type",
device_class=SensorDeviceClass.ENUM,
options=_DEPARTURE_TYPE_OPTIONS,
departure_index=2,
value_fn=lambda p: p.type.name.lower(),
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
coordinator = entry.runtime_data
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
DataGrandLyonStopSensor(coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
class DataGrandLyonStopSensor(
CoordinatorEntity[DataGrandLyonCoordinator], SensorEntity
):
"""Sensor for Data Grand Lyon stop departures."""
_attr_has_entity_name = True
entity_description: DataGrandLyonStopSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonStopSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._subentry_id = subentry.subentry_id
assert subentry.unique_id is not None
self._attr_unique_id = f"{subentry.unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.unique_id)},
name=subentry.title,
manufacturer="TCL",
model="Stop",
entry_type=DeviceEntryType.SERVICE,
)
def _get_departure(self) -> TclPassage | None:
"""Return the departure for this sensor's index, or None."""
departures = self.coordinator.data.get(self._subentry_id, [])
index = self.entity_description.departure_index
if index >= len(departures):
return None
return departures[index]
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
departure = self._get_departure()
if departure is None:
return None
return self.entity_description.value_fn(departure)
@@ -0,0 +1,105 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your password on data.grandlyon.com.",
"username": "Your username on data.grandlyon.com."
}
}
}
},
"config_subentries": {
"stop": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"entry_type": "Transit stop",
"initiate_flow": {
"user": "Add transit stop"
},
"step": {
"user": {
"data": {
"line": "Line",
"stop_id": "Stop ID"
}
}
}
}
},
"entity": {
"sensor": {
"next_departure_1": {
"name": "Next departure 1"
},
"next_departure_1_direction": {
"name": "Next departure 1 direction"
},
"next_departure_1_type": {
"name": "Next departure 1 type",
"state": {
"estimated": "Estimated",
"theoretical": "Theoretical"
}
},
"next_departure_2": {
"name": "Next departure 2"
},
"next_departure_2_direction": {
"name": "Next departure 2 direction"
},
"next_departure_2_type": {
"name": "Next departure 2 type",
"state": {
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
}
},
"next_departure_3": {
"name": "Next departure 3"
},
"next_departure_3_direction": {
"name": "Next departure 3 direction"
},
"next_departure_3_type": {
"name": "Next departure 3 type",
"state": {
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
}
}
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
"update_failed_all_stops": {
"message": "Error fetching Data Grand Lyon data: all requests failed."
}
}
}
+20 -19
View File
@@ -16,9 +16,11 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import callback
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_ADVANCED_OPTIONS,
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
@@ -37,15 +39,17 @@ from .const import (
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
}
)
DATA_SCHEMA_ADV = vol.Schema(
{
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
vol.Required(CONF_ADVANCED_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_RESOLVER): cv.string,
vol.Optional(CONF_PORT): cv.port,
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
vol.Optional(CONF_PORT_IPV6): cv.port,
}
),
SectionConfig(collapsed=True),
),
}
)
@@ -111,10 +115,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
hostname = user_input[CONF_HOSTNAME]
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6)
port = user_input.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT)
advanced_options = user_input[CONF_ADVANCED_OPTIONS]
resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
resolver_ipv6 = advanced_options.get(
CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6
)
port = advanced_options.get(CONF_PORT, DEFAULT_PORT)
port_ipv6 = advanced_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
validate = await async_validate_hostname(
hostname, resolver, resolver_ipv6, port, port_ipv6
@@ -149,12 +156,6 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
if self.show_advanced_options is True:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA_ADV,
errors=errors,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
+1
View File
@@ -12,6 +12,7 @@ CONF_PORT_IPV6 = "port_ipv6"
CONF_IPV4 = "ipv4"
CONF_IPV6 = "ipv6"
CONF_IPV6_V4 = "ipv6_v4"
CONF_ADVANCED_OPTIONS = "advanced_options"
DEFAULT_HOSTNAME = "myip.opendns.com"
DEFAULT_IPV6 = False
+30 -19
View File
@@ -9,18 +9,28 @@
"step": {
"user": {
"data": {
"hostname": "Hostname",
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
"resolver": "IPv4 resolver",
"resolver_ipv6": "IPv6 resolver"
"hostname": "Hostname"
},
"data_description": {
"hostname": "The hostname for which to perform the DNS query.",
"port": "Port used for the IPv4 lookup.",
"port_ipv6": "Port used for the IPv6 lookup.",
"resolver": "Resolver used for the IPv4 lookup.",
"resolver_ipv6": "Resolver used for the IPv6 lookup."
"hostname": "The hostname for which to perform the DNS query."
},
"sections": {
"advanced_options": {
"data": {
"port": "IPv4 port",
"port_ipv6": "IPv6 port",
"resolver": "IPv4 resolver",
"resolver_ipv6": "IPv6 resolver"
},
"data_description": {
"port": "Port used for the IPv4 lookup.",
"port_ipv6": "Port used for the IPv6 lookup.",
"resolver": "Resolver used for the IPv4 lookup.",
"resolver_ipv6": "Resolver used for the IPv6 lookup."
},
"description": "Optionally change resolvers and ports.",
"name": "Advanced options"
}
}
}
}
@@ -53,17 +63,18 @@
"step": {
"init": {
"data": {
"port": "[%key:component::dnsip::config::step::user::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]"
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver_ipv6%]"
},
"data_description": {
"port": "[%key:component::dnsip::config::step::user::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]"
}
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port%]",
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port_ipv6%]",
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver%]",
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver_ipv6%]"
},
"description": "Optionally change resolvers and ports."
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.4.0"],
"requirements": ["python-duco-client==0.5.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
@@ -102,5 +102,10 @@
"rate_limit_exceeded": {
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
}
},
"system_health": {
"info": {
"write_requests_remaining": "Remaining write requests today"
}
}
}
@@ -0,0 +1,45 @@
"""Provide info to system health."""
from typing import Any
from duco.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
from .coordinator import DucoConfigEntry
@callback
def async_register(
hass: HomeAssistant, register: system_health.SystemHealthRegistration
) -> None:
"""Register system health callbacks."""
register.async_register_info(system_health_info)
async def _async_get_write_requests_remaining(
config_entry: DucoConfigEntry,
) -> int | dict[str, str]:
"""Get the remaining write-request quota for system health."""
try:
return await config_entry.runtime_data.client.async_get_write_req_remaining()
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
"""Get info for the info page."""
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not config_entries:
return {}
return {
"write_requests_remaining": _async_get_write_requests_remaining(
config_entries[0]
)
}
@@ -2,7 +2,11 @@
from typing import Any
from easyenergy import EasyEnergy, EasyEnergyConnectionError
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -16,14 +20,22 @@ class EasyEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user")
if user_input is not None:
easyenergy = EasyEnergy(session=async_get_clientsession(self.hass))
today = dt_util.now().date()
try:
await easyenergy.energy_prices(start_date=today, end_date=today)
except EasyEnergyConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title="easyEnergy",
data={},
)
return self.async_create_entry(
title="easyEnergy",
data={},
)
return self.async_show_form(step_id="user", errors=errors)
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==3.0.0"],
"requirements": ["easyenergy==3.0.1"],
"single_config_entry": true
}
@@ -3,6 +3,9 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"step": {
"user": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
}
@@ -72,9 +72,11 @@ PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,
Platform.SCENE,
Platform.SENSOR,
Platform.SWITCH,
Platform.TIME,
]
+77
View File
@@ -0,0 +1,77 @@
"""Support for ElkM1 number entities."""
import logging
from typing import Any, cast
from elkm1_lib.const import SettingFormat
from elkm1_lib.elements import Element
from elkm1_lib.settings import Setting
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
from .models import ELKM1Data
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk-M1 number platform."""
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
number_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format in (SettingFormat.NUMBER, SettingFormat.TIMER)
]
create_elk_entities(
elk_data,
number_settings,
"setting",
ElkNumberSetting,
entities,
)
async_add_entities(entities)
class ElkNumberSetting(ElkAttachedEntity, NumberEntity):
"""Representation of an Elk-M1 Number Setting."""
_element: Setting
_attr_native_min_value = 0
_attr_native_max_value = 65535
_attr_native_step = 1
def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None:
"""Initialize the number setting."""
super().__init__(element, elk, elk_data)
if element.value_format == SettingFormat.TIMER:
self._attr_device_class = NumberDeviceClass.DURATION
self._attr_native_unit_of_measurement = UnitOfTime.SECONDS
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
# Guard against the panel possibly changing the underlying
# type without us knowing about the change
if isinstance(self._element.value, int):
self._attr_native_value = self._element.value
else:
self._attr_available = False
_LOGGER.warning(
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
self.entity_id,
)
async def async_set_native_value(self, value: float) -> None:
"""Set the value of the setting."""
self._element.set(int(value))
+66
View File
@@ -0,0 +1,66 @@
"""Support for ElkM1 time entities."""
from datetime import time as dt_time
import logging
from typing import Any, cast
from elkm1_lib.const import SettingFormat
from elkm1_lib.elements import Element
from elkm1_lib.settings import Setting
from homeassistant.components.time import TimeEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk-M1 time platform."""
elk_data = config_entry.runtime_data
elk = elk_data.elk
entities: list[ElkEntity] = []
time_settings = [
setting
for setting in cast(list[Setting], elk.settings)
if setting.value_format == SettingFormat.TIME_OF_DAY
]
create_elk_entities(
elk_data,
time_settings,
"setting",
ElkTimeSetting,
entities,
)
async_add_entities(entities)
class ElkTimeSetting(ElkAttachedEntity, TimeEntity):
"""Representation of an Elk-M1 Time Setting."""
_element: Setting
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
value = self._element.value
# Guard against the panel possibly changing the underlying
# type without us knowing about the change
if isinstance(value, tuple):
self._attr_native_value = dt_time(hour=value[0], minute=value[1])
else:
self._attr_available = False
_LOGGER.warning(
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
self.entity_id,
)
async def async_set_value(self, value: dt_time) -> None:
"""Set the time of the setting."""
self._element.set((value.hour, value.minute))
@@ -8,6 +8,7 @@ from functools import partial
import logging
import re
from typing import Any, TypedDict, cast
from xml.etree.ElementTree import ParseError
from fritzconnection import FritzConnection
from fritzconnection.core.exceptions import FritzActionError
@@ -24,7 +25,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -226,7 +227,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
self.fritz_status = FritzStatus(fc=self.connection)
self.fritz_call = FritzCall(fc=self.connection)
info = self.fritz_status.get_device_info()
try:
info = self.fritz_status.get_device_info()
except ParseError as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="error_parse_device_info",
) from ex
_LOGGER.debug(
"gathered device info of %s %s",
@@ -185,6 +185,9 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"error_parse_device_info": {
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
+2 -2
View File
@@ -16,6 +16,8 @@ from .coordinator import FritzboxDataUpdateCoordinator
class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
"""Basis FritzBox entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: FritzboxDataUpdateCoordinator,
@@ -27,11 +29,9 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
self.ain = ain
if entity_description is not None:
self._attr_has_entity_name = True
self.entity_description = entity_description
self._attr_unique_id = f"{ain}_{entity_description.key}"
else:
self._attr_name = self.data.name
self._attr_unique_id = ain
@property
+17 -2
View File
@@ -1,4 +1,9 @@
{
"common": {
"data_description_host": "The hostname or IP address of your FRITZ!Box router.",
"data_description_password": "Password for the user to connect Home Assistant to your FRITZ!Box.",
"data_description_username": "Name of the user to connect Home Assistant to your FRITZ!Box."
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -20,6 +25,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Do you want to set up {name}?"
},
"reauth_confirm": {
@@ -27,6 +36,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Update your login information for {name}."
},
"reconfigure": {
@@ -34,7 +47,7 @@
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router."
"host": "[%key:component::fritzbox::common::data_description_host%]"
},
"description": "Update your configuration information for {name}."
},
@@ -45,7 +58,9 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router."
"host": "[%key:component::fritzbox::common::data_description_host%]",
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Enter your FRITZ!Box information."
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["afsapi"],
"requirements": ["afsapi==1.0.0"],
"requirements": ["afsapi==1.0.1"],
"ssdp": [
{
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
@@ -196,7 +196,9 @@ class AFSAPIDevice(MediaPlayerEntity):
if not self._attr_source_list:
self.__modes_by_label = {
(mode.label or mode.id): mode.key for mode in await afsapi.get_modes()
(mode.label or mode.id): mode.key
for mode in await afsapi.get_modes()
if mode.selectable
}
self._attr_source_list = list(self.__modes_by_label)
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.10.0"]
"requirements": ["homematicip==2.11.0"]
}
@@ -4,8 +4,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from python_qube_heatpump.models import QubeState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@@ -13,6 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from .coordinator import QubeData
from .entity import QubeEntity
PARALLEL_UPDATES = 0
@@ -29,7 +28,7 @@ if TYPE_CHECKING:
class QubeBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Binary sensor entity description for Qube Heat Pump."""
value_fn: Callable[[QubeState], bool | None]
value_fn: Callable[[QubeData], bool | None]
BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
@@ -37,58 +36,58 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
QubeBinarySensorEntityDescription(
key="source_pump",
translation_key="source_pump",
value_fn=lambda data: data.dout_srcpmp_val,
value_fn=lambda data: data.state.dout_srcpmp_val,
),
QubeBinarySensorEntityDescription(
key="user_pump",
translation_key="user_pump",
value_fn=lambda data: data.dout_usrpmp_val,
value_fn=lambda data: data.state.dout_usrpmp_val,
),
QubeBinarySensorEntityDescription(
key="four_way_valve",
translation_key="four_way_valve",
value_fn=lambda data: data.dout_fourwayvlv_val,
value_fn=lambda data: data.state.dout_fourwayvlv_val,
),
QubeBinarySensorEntityDescription(
key="cooling_output",
translation_key="cooling_output",
value_fn=lambda data: data.dout_cooling_val,
value_fn=lambda data: data.state.dout_cooling_val,
),
QubeBinarySensorEntityDescription(
key="three_way_valve",
translation_key="three_way_valve",
value_fn=lambda data: data.dout_threewayvlv_val,
value_fn=lambda data: data.state.dout_threewayvlv_val,
),
QubeBinarySensorEntityDescription(
key="buffer_pump",
translation_key="buffer_pump",
value_fn=lambda data: data.dout_bufferpmp_val,
value_fn=lambda data: data.state.dout_bufferpmp_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_1",
translation_key="heater_step_1",
value_fn=lambda data: data.dout_heaterstep1_val,
value_fn=lambda data: data.state.dout_heaterstep1_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_2",
translation_key="heater_step_2",
value_fn=lambda data: data.dout_heaterstep2_val,
value_fn=lambda data: data.state.dout_heaterstep2_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_3",
translation_key="heater_step_3",
value_fn=lambda data: data.dout_heaterstep3_val,
value_fn=lambda data: data.state.dout_heaterstep3_val,
),
# System status
QubeBinarySensorEntityDescription(
key="keypad",
translation_key="keypad",
value_fn=lambda data: data.keybonoff,
value_fn=lambda data: data.state.keybonoff,
),
QubeBinarySensorEntityDescription(
key="day_mode",
translation_key="day_mode",
value_fn=lambda data: data.daynightmode,
value_fn=lambda data: data.state.daynightmode,
),
# Alarms
QubeBinarySensorEntityDescription(
@@ -96,84 +95,84 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
translation_key="alarm_antilegionella_timeout",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_maxtime_antileg_active,
value_fn=lambda data: data.state.al_maxtime_antileg_active,
),
QubeBinarySensorEntityDescription(
key="alarm_dhw_timeout",
translation_key="alarm_dhw_timeout",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_maxtime_dhw_active,
value_fn=lambda data: data.state.al_maxtime_dhw_active,
),
QubeBinarySensorEntityDescription(
key="alarm_dewpoint",
translation_key="alarm_dewpoint",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_dewpoint_active,
value_fn=lambda data: data.state.al_dewpoint_active,
),
QubeBinarySensorEntityDescription(
key="alarm_supply_too_hot",
translation_key="alarm_supply_too_hot",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.al_underfloorsafety_active,
value_fn=lambda data: data.state.al_underfloorsafety_active,
),
QubeBinarySensorEntityDescription(
key="alarm_flow",
translation_key="alarm_flow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alrm_flw,
value_fn=lambda data: data.state.alrm_flw,
),
QubeBinarySensorEntityDescription(
key="alarm_central_heating",
translation_key="alarm_central_heating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.usralrms,
value_fn=lambda data: data.state.usralrms,
),
QubeBinarySensorEntityDescription(
key="alarm_cooling",
translation_key="alarm_cooling",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.coolingalrms,
value_fn=lambda data: data.state.coolingalrms,
),
QubeBinarySensorEntityDescription(
key="alarm_heating",
translation_key="alarm_heating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.heatingalrms,
value_fn=lambda data: data.state.heatingalrms,
),
QubeBinarySensorEntityDescription(
key="alarm_working_hours",
translation_key="alarm_working_hours",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alarmmng_al_workinghour,
value_fn=lambda data: data.state.alarmmng_al_workinghour,
),
QubeBinarySensorEntityDescription(
key="alarm_source",
translation_key="alarm_source",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.srsalrm,
value_fn=lambda data: data.state.srsalrm,
),
QubeBinarySensorEntityDescription(
key="alarm_global",
translation_key="alarm_global",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.glbal,
value_fn=lambda data: data.state.glbal,
),
QubeBinarySensorEntityDescription(
key="alarm_compressor",
translation_key="alarm_compressor",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.alarmmng_al_pwrplus,
value_fn=lambda data: data.state.alarmmng_al_pwrplus,
),
# Sensor/controller status
QubeBinarySensorEntityDescription(
@@ -181,76 +180,76 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
translation_key="room_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.roomprb_en,
value_fn=lambda data: data.state.roomprb_en,
),
QubeBinarySensorEntityDescription(
key="plant_sensor_enabled",
translation_key="plant_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.plantprb_en,
value_fn=lambda data: data.state.plantprb_en,
),
QubeBinarySensorEntityDescription(
key="buffer_sensor_enabled",
translation_key="buffer_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.bufferprb_en,
value_fn=lambda data: data.state.bufferprb_en,
),
QubeBinarySensorEntityDescription(
key="dhw_controller_enabled",
translation_key="dhw_controller_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.en_dhwpid,
value_fn=lambda data: data.state.en_dhwpid,
),
# Demand signals
QubeBinarySensorEntityDescription(
key="plant_demand",
translation_key="plant_demand",
value_fn=lambda data: data.plantdemand,
value_fn=lambda data: data.state.plantdemand,
),
QubeBinarySensorEntityDescription(
key="external_demand",
translation_key="external_demand",
value_fn=lambda data: data.id_demand,
value_fn=lambda data: data.state.id_demand,
),
QubeBinarySensorEntityDescription(
key="thermostat_demand",
translation_key="thermostat_demand",
value_fn=lambda data: data.thermostatdemand,
value_fn=lambda data: data.state.thermostatdemand,
),
# Digital inputs
QubeBinarySensorEntityDescription(
key="summer_mode",
translation_key="summer_mode",
value_fn=lambda data: data.id_summerwinter,
value_fn=lambda data: data.state.id_summerwinter,
),
QubeBinarySensorEntityDescription(
key="dewpoint",
translation_key="dewpoint",
value_fn=lambda data: data.dewpoint,
value_fn=lambda data: data.state.dewpoint,
),
QubeBinarySensorEntityDescription(
key="booster_security",
translation_key="booster_security",
value_fn=lambda data: data.boostersecurity,
value_fn=lambda data: data.state.boostersecurity,
),
QubeBinarySensorEntityDescription(
key="source_flow",
translation_key="source_flow",
value_fn=lambda data: data.srcflw,
value_fn=lambda data: data.state.srcflw,
),
QubeBinarySensorEntityDescription(
key="anti_legionella",
translation_key="anti_legionella",
value_fn=lambda data: data.req_antileg_1,
value_fn=lambda data: data.state.req_antileg_1,
),
# Energy
QubeBinarySensorEntityDescription(
key="pv_surplus",
translation_key="pv_surplus",
value_fn=lambda data: data.surplus_pv,
value_fn=lambda data: data.state.surplus_pv,
),
)
@@ -3,7 +3,12 @@
from homeassistant.const import Platform
DOMAIN = "hr_energy_qube"
PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR)
PLATFORMS = (
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
)
DEFAULT_PORT = 502
DEFAULT_SCAN_INTERVAL = 15
@@ -1,5 +1,6 @@
"""DataUpdateCoordinator for Qube Heat Pump."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
@@ -18,7 +19,15 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
class QubeCoordinator(DataUpdateCoordinator[QubeState]):
@dataclass
class QubeData:
"""Data from the Qube coordinator."""
state: QubeState
switches: dict[str, bool | None]
class QubeCoordinator(DataUpdateCoordinator[QubeData]):
"""Qube Heat Pump data coordinator."""
def __init__(
@@ -34,16 +43,17 @@ class QubeCoordinator(DataUpdateCoordinator[QubeState]):
config_entry=entry,
)
async def _async_update_data(self) -> QubeState:
async def _async_update_data(self) -> QubeData:
"""Fetch data from the device."""
try:
data = await self.client.get_all_data()
state = await self.client.get_all_data()
switches = await self.client.read_all_switches()
except (ConnectionError, TimeoutError, OSError) as exc:
raise UpdateFailed(
f"Error communicating with Qube heat pump: {exc}"
) from exc
if data is None:
if state is None:
raise UpdateFailed("No data received from Qube heat pump")
return data
return QubeData(state=state, switches=switches)
@@ -4,8 +4,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from python_qube_heatpump.models import QubeState
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -21,6 +19,7 @@ from homeassistant.const import (
)
from homeassistant.helpers.typing import StateType
from .coordinator import QubeData
from .entity import QubeEntity
PARALLEL_UPDATES = 0
@@ -52,12 +51,12 @@ STATUS_MAP: dict[int, str] = {
class QubeSensorEntityDescription(SensorEntityDescription):
"""Sensor entity description for Qube Heat Pump."""
value_fn: Callable[[QubeState], StateType]
value_fn: Callable[[QubeData], StateType]
def _status_value(data: QubeState) -> StateType:
def _status_value(data: QubeData) -> StateType:
"""Return status string from status code."""
code = data.status_code
code = data.state.status_code
if code is None:
return None
return STATUS_MAP.get(code)
@@ -71,7 +70,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_supply,
value_fn=lambda data: data.state.temp_supply,
),
QubeSensorEntityDescription(
key="temp_return",
@@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_return,
value_fn=lambda data: data.state.temp_return,
),
QubeSensorEntityDescription(
key="temp_source_in",
@@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_source_in,
value_fn=lambda data: data.state.temp_source_in,
),
QubeSensorEntityDescription(
key="temp_source_out",
@@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_source_out,
value_fn=lambda data: data.state.temp_source_out,
),
QubeSensorEntityDescription(
key="temp_room",
@@ -107,7 +106,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_room,
value_fn=lambda data: data.state.temp_room,
),
QubeSensorEntityDescription(
key="temp_dhw",
@@ -116,7 +115,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_dhw,
value_fn=lambda data: data.state.temp_dhw,
),
QubeSensorEntityDescription(
key="temp_outside",
@@ -125,7 +124,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.temp_outside,
value_fn=lambda data: data.state.temp_outside,
),
QubeSensorEntityDescription(
key="power_thermic",
@@ -134,7 +133,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.power_thermic,
value_fn=lambda data: data.state.power_thermic,
),
QubeSensorEntityDescription(
key="power_electric",
@@ -143,7 +142,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.power_electric,
value_fn=lambda data: data.state.power_electric,
),
QubeSensorEntityDescription(
key="energy_total_electric",
@@ -152,7 +151,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_fn=lambda data: data.energy_total_electric,
value_fn=lambda data: data.state.energy_total_electric,
),
QubeSensorEntityDescription(
key="energy_total_thermic",
@@ -161,14 +160,14 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_fn=lambda data: data.energy_total_thermic,
value_fn=lambda data: data.state.energy_total_thermic,
),
QubeSensorEntityDescription(
key="cop_calc",
translation_key="cop_calc",
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.cop_calc,
value_fn=lambda data: data.state.cop_calc,
),
QubeSensorEntityDescription(
key="compressor_speed",
@@ -176,7 +175,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.compressor_speed,
value_fn=lambda data: data.state.compressor_speed,
),
QubeSensorEntityDescription(
key="flow_rate",
@@ -185,7 +184,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.flow_rate,
value_fn=lambda data: data.state.flow_rate,
),
QubeSensorEntityDescription(
key="setpoint_room_heat_day",
@@ -194,7 +193,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_heat_day,
value_fn=lambda data: data.state.setpoint_room_heat_day,
),
QubeSensorEntityDescription(
key="setpoint_room_heat_night",
@@ -203,7 +202,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_heat_night,
value_fn=lambda data: data.state.setpoint_room_heat_night,
),
QubeSensorEntityDescription(
key="setpoint_room_cool_day",
@@ -212,7 +211,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_cool_day,
value_fn=lambda data: data.state.setpoint_room_cool_day,
),
QubeSensorEntityDescription(
key="setpoint_room_cool_night",
@@ -221,7 +220,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.setpoint_room_cool_night,
value_fn=lambda data: data.state.setpoint_room_cool_night,
),
QubeSensorEntityDescription(
key="status_heatpump",
@@ -199,6 +199,33 @@
"temp_supply": {
"name": "Supply temperature CH"
}
},
"switch": {
"anti_legionella_cycle": {
"name": "Anti-legionella cycle"
},
"heating_curve": {
"name": "Heating curve"
},
"heating_demand": {
"name": "Heating demand"
},
"summer_mode": {
"name": "Summer mode"
}
},
"water_heater": {
"water_heater": {
"name": "Domestic hot water"
}
}
},
"exceptions": {
"set_temperature_failed": {
"message": "Failed to set the target temperature."
},
"switch_command_failed": {
"message": "Failed to send command to the heat pump."
}
}
}
@@ -0,0 +1,117 @@
"""Switch platform for Qube Heat Pump."""
from dataclasses import dataclass
from typing import Any
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 . import QubeConfigEntry
from .const import DOMAIN
from .coordinator import QubeCoordinator
from .entity import QubeEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class QubeSwitchEntityDescription(SwitchEntityDescription):
"""Switch entity description for Qube Heat Pump."""
register_key: str
SWITCH_TYPES: tuple[QubeSwitchEntityDescription, ...] = (
QubeSwitchEntityDescription(
key="summer_mode",
translation_key="summer_mode",
register_key="bms_summerwinter",
),
QubeSwitchEntityDescription(
key="anti_legionella_cycle",
translation_key="anti_legionella_cycle",
register_key="antilegionella_frcstart_ant",
),
QubeSwitchEntityDescription(
key="heating_curve",
translation_key="heating_curve",
entity_category=EntityCategory.CONFIG,
register_key="en_plantsetp_compens",
),
QubeSwitchEntityDescription(
key="heating_demand",
translation_key="heating_demand",
register_key="modbus_demand",
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube switches."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
QubeSwitch(coordinator, entry, description) for description in SWITCH_TYPES
)
class QubeSwitch(QubeEntity, SwitchEntity):
"""Qube switch entity."""
entity_description: QubeSwitchEntityDescription
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
description: QubeSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator, entry)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}-{description.key}"
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.entity_description.register_key in self.coordinator.data.switches
)
@property
def is_on(self) -> bool | None:
"""Return true if the switch is on."""
return self.coordinator.data.switches.get(self.entity_description.register_key)
async def _async_write_switch(self, value: bool) -> None:
"""Write switch value to the device."""
register_key = self.entity_description.register_key
try:
success = await self.coordinator.client.write_switch(register_key, value)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._async_write_switch(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._async_write_switch(False)
@@ -0,0 +1,119 @@
"""Water heater platform for Qube Heat Pump."""
from typing import Any
from homeassistant.components.water_heater import (
STATE_HEAT_PUMP,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import QubeConfigEntry
from .const import DOMAIN
from .coordinator import QubeCoordinator
from .entity import QubeEntity
PARALLEL_UPDATES = 1
DHW_BOOST_KEY = "tapw_timeprogram_bms_forced"
DHW_SETPOINT_KEY = "setpoint_dhw"
DHW_MIN_TEMP = 40
DHW_MAX_TEMP = 65
OPERATION_MODES = [STATE_HEAT_PUMP, STATE_PERFORMANCE]
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube water heater."""
coordinator = entry.runtime_data.coordinator
async_add_entities([QubeWaterHeater(coordinator, entry)])
class QubeWaterHeater(QubeEntity, WaterHeaterEntity):
"""Qube DHW water heater entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = DHW_MIN_TEMP
_attr_max_temp = DHW_MAX_TEMP
_attr_operation_list = OPERATION_MODES
_attr_supported_features = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
| WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_translation_key = "water_heater"
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
) -> None:
"""Initialize the water heater."""
super().__init__(coordinator, entry)
self._attr_unique_id = entry.entry_id
@property
def current_temperature(self) -> float | None:
"""Return the current DHW temperature."""
return self.coordinator.data.state.temp_dhw
@property
def target_temperature(self) -> float | None:
"""Return the target DHW temperature."""
return self.coordinator.data.state.setpoint_dhw
@property
def current_operation(self) -> str | None:
"""Return the current operation mode."""
boost = self.coordinator.data.switches.get(DHW_BOOST_KEY)
if boost is None:
return None
if boost:
return STATE_PERFORMANCE
return STATE_HEAT_PUMP
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target DHW temperature."""
temperature = kwargs.get("temperature")
if temperature is None:
return
try:
success = await self.coordinator.client.write_setpoint(
DHW_SETPOINT_KEY, temperature
)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
)
await self.coordinator.async_request_refresh()
async def async_set_operation_mode(self, operation_mode: str) -> None:
"""Set the operation mode."""
boost = operation_mode == STATE_PERFORMANCE
try:
success = await self.coordinator.client.write_switch(DHW_BOOST_KEY, boost)
except (ConnectionError, TimeoutError, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
) from err
if not success:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
)
await self.coordinator.async_request_refresh()
@@ -46,6 +46,11 @@ async def async_setup_entry(
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
hass, config_entry, hydrawise, main_coordinator
)
# async_track_zones is registered first on water_use_coordinator,
# so the water-use coordinator's data is in sync before
# callbacks below construct entities for newly added zones.
water_use_coordinator.async_track_zones()
main_coordinator.async_track_zones()
await water_use_coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = HydrawiseUpdateCoordinators(
main=main_coordinator,
@@ -82,6 +82,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
self.new_zones_callbacks: list[
Callable[[Iterable[tuple[Zone, Controller]]], None]
] = []
@callback
def async_track_zones(self) -> None:
"""Begin tracking zone and controller add/remove on updates."""
self.async_add_listener(self._add_remove_zones)
async def _async_update_data(self) -> HydrawiseData:
@@ -198,6 +202,23 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
self.api = api
self._main_coordinator = main_coordinator
@callback
def async_track_zones(self) -> None:
"""Begin tracking zone and controller add/remove on updates."""
self._main_coordinator.async_add_listener(self._sync_data_from_main)
@callback
def _sync_data_from_main(self) -> None:
"""Sync data references from the main coordinator after it updates."""
if self.data is None or self._main_coordinator.data is None:
return # type: ignore[unreachable]
main_data = self._main_coordinator.data
self.data.user = main_data.user
self.data.controllers = main_data.controllers
self.data.zones = main_data.zones
self.data.zone_id_to_controller = main_data.zone_id_to_controller
self.data.sensors = main_data.sensors
async def _async_update_data(self) -> HydrawiseData:
"""Fetch the latest data from Hydrawise."""
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
@@ -5,7 +5,7 @@ from datetime import timedelta
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from infrared_protocols.commands import Command as InfraredCommand
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==2.1.0"]
"requirements": ["infrared-protocols==3.5.0"]
}
@@ -2,6 +2,7 @@
import asyncio
import aiohttp
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
@@ -153,6 +154,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
raise ConfigEntryNotReady(
"Initialization of fireplace timed out after 10 minutes"
) from err
except (aiohttp.ClientConnectionError, ConnectionError) as err:
raise ConfigEntryNotReady(
"Error communicating with fireplace during initialization"
) from err
# Construct coordinator
data_update_coordinator = IntellifireDataUpdateCoordinator(hass, entry, fireplace)
@@ -13,6 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -287,10 +288,8 @@ class IntelliFireOptionsFlowHandler(OptionsFlow):
errors: dict[str, str] = {}
if user_input is not None:
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
if self.config_entry.state is ConfigEntryState.LOADED:
fireplace = self.config_entry.runtime_data.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()
+2 -2
View File
@@ -2,7 +2,7 @@
from typing import Any
import infrared_protocols
from infrared_protocols.commands.nec import NECCommand
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.components.infrared import async_send_command
@@ -102,7 +102,7 @@ class DemoInfraredFan(FanEntity):
async def _send_command(self, command_code: int) -> None:
"""Send an IR command using the NEC protocol."""
command = infrared_protocols.NECCommand(
command = NECCommand(
address=DUMMY_FAN_ADDRESS,
command=command_code,
modulation=38000,
@@ -1,6 +1,6 @@
"""Demo platform that offers a fake infrared entity."""
import infrared_protocols
from infrared_protocols.commands import Command as InfraredCommand
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
@@ -51,7 +51,7 @@ class DemoInfrared(InfraredEntity):
)
self._attr_name = entity_name
async def async_send_command(self, command: infrared_protocols.Command) -> None:
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
persistent_notification.async_create(
self.hass, str(command.get_raw_timings()), title="Infrared Command"
@@ -2,7 +2,7 @@
import logging
from infrared_protocols.codes.lg.tv import LGTVCode, make_command as make_lg_tv_command
from infrared_protocols.codes.lg.tv import LGTVCode
from homeassistant.components.infrared import async_send_command
from homeassistant.config_entries import ConfigEntry
@@ -71,6 +71,6 @@ class LgIrEntity(Entity):
await async_send_command(
self.hass,
self._infrared_entity_id,
make_lg_tv_command(code),
code.to_command(),
context=self._context,
)
@@ -0,0 +1,90 @@
{
"entity": {
"button": {
"back": {
"default": "mdi:keyboard-backspace"
},
"down": {
"default": "mdi:arrow-down"
},
"exit": {
"default": "mdi:exit-to-app"
},
"guide": {
"default": "mdi:television-guide"
},
"hdmi_1": {
"default": "mdi:video-input-hdmi"
},
"hdmi_2": {
"default": "mdi:video-input-hdmi"
},
"hdmi_3": {
"default": "mdi:video-input-hdmi"
},
"hdmi_4": {
"default": "mdi:video-input-hdmi"
},
"home": {
"default": "mdi:home"
},
"info": {
"default": "mdi:information-outline"
},
"input": {
"default": "mdi:import"
},
"left": {
"default": "mdi:arrow-left"
},
"menu": {
"default": "mdi:menu"
},
"num_0": {
"default": "mdi:numeric-0"
},
"num_1": {
"default": "mdi:numeric-1"
},
"num_2": {
"default": "mdi:numeric-2"
},
"num_3": {
"default": "mdi:numeric-3"
},
"num_4": {
"default": "mdi:numeric-4"
},
"num_5": {
"default": "mdi:numeric-5"
},
"num_6": {
"default": "mdi:numeric-6"
},
"num_7": {
"default": "mdi:numeric-7"
},
"num_8": {
"default": "mdi:numeric-8"
},
"num_9": {
"default": "mdi:numeric-9"
},
"ok": {
"default": "mdi:check"
},
"power_off": {
"default": "mdi:power-off"
},
"power_on": {
"default": "mdi:power-on"
},
"right": {
"default": "mdi:arrow-right"
},
"up": {
"default": "mdi:arrow-up"
}
}
}
}
+131 -4
View File
@@ -18,6 +18,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from . import ThinqConfigEntry
@@ -33,6 +35,11 @@ class ThinQFanEntityDescription(FanEntityDescription):
preset_modes: list[str] | None = None
HOOD_FAN_DESC = FanEntityDescription(
key=ThinQProperty.FAN_SPEED,
translation_key=ThinQProperty.FAN_SPEED,
)
DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = {
DeviceType.CEILING_FAN: (
ThinQFanEntityDescription(
@@ -52,6 +59,8 @@ DEVICE_TYPE_FAN_MAP: dict[DeviceType, tuple[ThinQFanEntityDescription, ...]] = {
),
}
HOOD_DEVICE_TYPES: set[DeviceType] = {DeviceType.HOOD, DeviceType.MICROWAVE_OVEN}
ORDERED_NAMED_FAN_SPEEDS = ["low", "mid", "high", "turbo", "power"]
_LOGGER = logging.getLogger(__name__)
@@ -63,11 +72,20 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for fan platform."""
entities: list[ThinQFanEntity] = []
entities: list[ThinQFanEntity | ThinQHoodFanEntity] = []
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_FAN_MAP.get(coordinator.api.device.device_type)
) is not None:
device_type = coordinator.api.device.device_type
# Handle hood-type devices with numeric fan speed
if device_type in HOOD_DEVICE_TYPES:
entities.extend(
ThinQHoodFanEntity(coordinator, HOOD_FAN_DESC, property_id)
for property_id in coordinator.api.get_active_idx(
HOOD_FAN_DESC.key, ActiveMode.READ_WRITE
)
)
# Handle other fan devices with named speeds
elif (descriptions := DEVICE_TYPE_FAN_MAP.get(device_type)) is not None:
for description in descriptions:
entities.extend(
ThinQFanEntity(coordinator, description, property_id)
@@ -210,3 +228,112 @@ class ThinQFanEntity(ThinQEntity, FanEntity):
await self.async_call_api(
self.coordinator.api.async_turn_off(self._operation_id)
)
class ThinQHoodFanEntity(ThinQEntity, FanEntity):
"""Represent a thinq hood fan platform.
Hood fans use numeric speed values (e.g., 0=off, 1=low, 2=high)
rather than named speed presets.
"""
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
entity_description: FanEntityDescription,
property_id: str,
) -> None:
"""Initialize hood fan platform."""
super().__init__(coordinator, entity_description, property_id)
self._min_speed: int = int(self.data.min)
self._max_speed: int = int(self.data.max)
# Speed count is the number of non-zero speeds
self._attr_speed_count = self._max_speed - self._min_speed
@property
def _speed_range(self) -> tuple[int, int]:
"""Return the speed range excluding off (0)."""
return (self._min_speed + 1, self._max_speed)
def _update_status(self) -> None:
"""Update status itself."""
super()._update_status()
# Get current speed value
current_speed = self.data.value
if current_speed is None or current_speed == self._min_speed:
self._attr_is_on = False
self._attr_percentage = 0
else:
self._attr_is_on = True
self._attr_percentage = ranged_value_to_percentage(
self._speed_range, current_speed
)
_LOGGER.debug(
"[%s:%s] update status: is_on=%s, percentage=%s, speed=%s, min=%s, max=%s",
self.coordinator.device_name,
self.property_id,
self.is_on,
self.percentage,
current_speed,
self._min_speed,
self._max_speed,
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
speed = round(percentage_to_ranged_value(self._speed_range, percentage))
_LOGGER.debug(
"[%s:%s] async_set_percentage: percentage=%s -> speed=%s",
self.coordinator.device_name,
self.property_id,
percentage,
speed,
)
await self.async_call_api(self.coordinator.api.post(self.property_id, speed))
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
# Default to lowest non-zero speed
speed = self._min_speed + 1
_LOGGER.debug(
"[%s:%s] async_turn_on: speed=%s",
self.coordinator.device_name,
self.property_id,
speed,
)
await self.async_call_api(self.coordinator.api.post(self.property_id, speed))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
_LOGGER.debug(
"[%s:%s] async_turn_off",
self.coordinator.device_name,
self.property_id,
)
await self.async_call_api(
self.coordinator.api.post(self.property_id, self._min_speed)
)
+91 -10
View File
@@ -6,23 +6,33 @@ from thinqconnect import DeviceType
from thinqconnect.devices.const import Property as ThinQProperty
from thinqconnect.integration import ActiveMode, TimerProperty
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import ThinqConfigEntry
from .const import DOMAIN
from .entity import ThinQEntity
NUMBER_DESC: dict[ThinQProperty, NumberEntityDescription] = {
ThinQProperty.FAN_SPEED: NumberEntityDescription(
key=ThinQProperty.FAN_SPEED,
translation_key=ThinQProperty.FAN_SPEED,
entity_registry_enabled_default=False,
),
ThinQProperty.LAMP_BRIGHTNESS: NumberEntityDescription(
key=ThinQProperty.LAMP_BRIGHTNESS,
@@ -126,9 +136,71 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
),
}
DEPRECATED_FAN_SPEED_DEVICE_TYPES: set[DeviceType] = {
DeviceType.HOOD,
DeviceType.MICROWAVE_OVEN,
}
_LOGGER = logging.getLogger(__name__)
def _check_deprecated_fan_speed_entity(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
unique_id: str,
) -> bool:
"""Check if a deprecated fan speed number entity should be created.
Returns True if the entity exists and is enabled (should still be created).
"""
if not (
entity_id := entity_registry.async_get_entity_id("number", DOMAIN, unique_id)
):
return False
entity_entry = entity_registry.async_get(entity_id)
if not entity_entry:
return False
if entity_entry.disabled:
entity_registry.async_remove(entity_id)
async_delete_issue(hass, DOMAIN, f"deprecated_fan_speed_number_{entity_id}")
return False
translation_key = "deprecated_fan_speed_number"
placeholders: dict[str, str] = {
"entity_id": entity_id,
"entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
}
automation_entities = automations_with_entity(hass, entity_id)
script_entities = scripts_with_entity(hass, entity_id)
if automation_entities or script_entities:
translation_key = f"{translation_key}_scripts"
placeholders["items"] = "\n".join(
f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
for integration, entities in (
("automation", automation_entities),
("script", script_entities),
)
for eid in entities
if (item := entity_registry.async_get(eid))
)
async_create_issue(
hass,
DOMAIN,
f"deprecated_fan_speed_number_{entity_id}",
breaks_in_ha_version="2026.12.0",
is_fixable=True,
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=placeholders,
data={"entity_id": entity_id, **placeholders},
)
return True
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
@@ -136,18 +208,27 @@ async def async_setup_entry(
) -> None:
"""Set up an entry for number platform."""
entities: list[ThinQNumberEntity] = []
entity_registry = er.async_get(hass)
for coordinator in entry.runtime_data.coordinators.values():
if (
descriptions := DEVICE_TYPE_NUMBER_MAP.get(
coordinator.api.device.device_type
)
) is not None:
for description in descriptions:
entities.extend(
descriptions = DEVICE_TYPE_NUMBER_MAP.get(coordinator.api.device.device_type)
if descriptions is None:
continue
for description in descriptions:
for property_id in coordinator.api.get_active_idx(
description.key, ActiveMode.READ_WRITE
):
if (
description.key == ThinQProperty.FAN_SPEED
and coordinator.api.device.device_type
in DEPRECATED_FAN_SPEED_DEVICE_TYPES
):
unique_id = f"{coordinator.unique_id}_{property_id}"
if not _check_deprecated_fan_speed_entity(
hass, entity_registry, unique_id
):
continue
entities.append(
ThinQNumberEntity(coordinator, description, property_id)
for property_id in coordinator.api.get_active_idx(
description.key, ActiveMode.READ_WRITE
)
)
if entities:
@@ -0,0 +1,53 @@
"""Repairs for LG ThinQ integration."""
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
class DeprecatedFanSpeedRepairFlow(RepairsFlow):
"""Handler for deprecated fan speed number entity fixing flow."""
def __init__(self, data: dict[str, str]) -> None:
"""Initialize."""
self.entity_id = data["entity_id"]
self._placeholders = data
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entity_registry = er.async_get(self.hass)
if entity_registry.async_get(self.entity_id):
entity_registry.async_update_entity(
self.entity_id,
disabled_by=er.RegistryEntryDisabler.USER,
)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=self._placeholders,
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str],
) -> RepairsFlow:
"""Create flow."""
if issue_id.startswith("deprecated_fan_speed_number_"):
return DeprecatedFanSpeedRepairFlow(data)
return ConfirmRepairFlow()
@@ -199,6 +199,11 @@
}
}
},
"fan": {
"fan_speed": {
"name": "Hood"
}
},
"humidifier": {
"dehumidifier": {
"state_attributes": {
@@ -1154,5 +1159,29 @@
"failed_to_connect_mqtt": {
"message": "Failed to connect MQTT: {error}"
}
},
"issues": {
"deprecated_fan_speed_number": {
"fix_flow": {
"step": {
"confirm": {
"description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nPlease update your dashboards and templates to use the new fan entity.\n\nAfter updating, click **Submit** to disable the number entity and fix this issue.",
"title": "Fan speed number entity deprecated"
}
}
},
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
},
"deprecated_fan_speed_number_scripts": {
"fix_flow": {
"step": {
"confirm": {
"description": "The number entity {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a fan entity.\n\nThe entity was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new fan entity.\n\nAfter updating, click **Submit** to disable the number entity and fix this issue.",
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
}
}
},
"title": "[%key:component::lg_thinq::issues::deprecated_fan_speed_number::fix_flow::step::confirm::title%]"
}
}
}
@@ -3,7 +3,7 @@
import logging
from typing import Final
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info, Sensors
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
@@ -18,10 +18,11 @@ from .coordinator import (
LunatoneData,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
LunatoneSensorsDataUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT]
PLATFORMS: Final[list[Platform]] = [Platform.LIGHT, Platform.SENSOR]
async def _update_unique_id(
@@ -70,6 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
info_api = Info(auth_api)
devices_api = Devices(info_api)
sensors_api = Sensors(auth_api)
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
await coordinator_info.async_config_entry_first_refresh()
@@ -105,6 +107,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
coordinator_sensors = LunatoneSensorsDataUpdateCoordinator(hass, entry, sensors_api)
await coordinator_sensors.async_config_entry_first_refresh()
dali_line_broadcasts = [
DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines
]
@@ -112,6 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
entry.runtime_data = LunatoneData(
coordinator_info,
coordinator_devices,
coordinator_sensors,
dali_line_broadcasts,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -5,7 +5,14 @@ from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import DALIBroadcast, Device, Devices, Info
from lunatone_rest_api_client import (
DALIBroadcast,
Device,
Devices,
Info,
Sensor,
Sensors,
)
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
@@ -18,6 +25,7 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
DEFAULT_SENSORS_SCAN_INTERVAL = timedelta(seconds=30)
@dataclass
@@ -26,6 +34,7 @@ class LunatoneData:
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
coordinator_sensors: LunatoneSensorsDataUpdateCoordinator
dali_line_broadcasts: list[DALIBroadcast]
@@ -98,5 +107,40 @@ class LunatoneDevicesDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Devic
if self.devices_api.data is None:
raise UpdateFailed("Did not receive devices data from Lunatone REST API")
return {device.id: device for device in self.devices_api.devices}
class LunatoneSensorsDataUpdateCoordinator(DataUpdateCoordinator[dict[int, Sensor]]):
"""Data update coordinator for Lunatone sensors."""
config_entry: LunatoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
sensors_api: Sensors,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}-sensors",
always_update=False,
update_interval=DEFAULT_SENSORS_SCAN_INTERVAL,
)
self.sensors_api = sensors_api
async def _async_update_data(self) -> dict[int, Sensor]:
"""Update sensor data."""
try:
await self.sensors_api.async_update()
except aiohttp.ClientConnectionError as ex:
raise UpdateFailed(
"Unable to retrieve sensors data from Lunatone REST API"
) from ex
if self.sensors_api.data is None:
raise UpdateFailed("Did not receive sensors data from Lunatone REST API")
return {sensor.id: sensor for sensor in self.sensors_api.sensors}
+157
View File
@@ -0,0 +1,157 @@
"""Platform for Lunatone sensor integration."""
from typing import Final
from lunatone_rest_api_client import Sensor
from lunatone_rest_api_client.models import SensorAddressType, SensorType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LunatoneConfigEntry, LunatoneSensorsDataUpdateCoordinator
PARALLEL_UPDATES = 0
SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
SensorType.AIR_HUMIDITY: SensorEntityDescription(
key="air_humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.AIR_PRESSURE: SensorEntityDescription(
key="air_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.AIR_QUALITY: SensorEntityDescription(
key="air_quality",
device_class=SensorDeviceClass.AQI,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.ECO2: SensorEntityDescription(
key="eco2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.LIGHT: SensorEntityDescription(
key="light",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.TEMPERATURE: SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
SensorType.VOC: SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LunatoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lunatone sensors from the config entry."""
coordinator_sensors = config_entry.runtime_data.coordinator_sensors
assert config_entry.unique_id is not None
async_add_entities(
LunatoneSensor(
coordinator_sensors, description, sensor_id, config_entry.unique_id
)
for sensor_id, sensor_data in coordinator_sensors.data.items()
if (description := SENSOR_TYPES.get(sensor_data.data.type))
)
class LunatoneSensor(
CoordinatorEntity[LunatoneSensorsDataUpdateCoordinator], SensorEntity
):
"""Representation of a Lunatone Sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: LunatoneSensorsDataUpdateCoordinator,
description: SensorEntityDescription,
sensor_id: int,
config_entry_unique_id: str,
) -> None:
"""Initialize a Lunatone Sensor."""
super().__init__(coordinator)
self.entity_description = description
self._config_entry_unique_id = config_entry_unique_id
self._sensor_id = sensor_id
self._attr_name = self.sensor.name
self._attr_unique_id = (
f"{config_entry_unique_id}-sensor{sensor_id}-{description.key}"
)
device_info = DeviceInfo(
identifiers={(DOMAIN, self._config_entry_unique_id)},
)
if (
self.sensor.data.address_type == SensorAddressType.DALI
and self.sensor.data.dali_sensor_address
):
device_info = DeviceInfo(
identifiers={
(
DOMAIN,
f"{self._config_entry_unique_id}"
f"-line{self.sensor.data.dali_sensor_address.line}"
f"-d24-address{self.sensor.data.dali_sensor_address.address}",
)
},
name=(
f"DALI Line {self.sensor.data.dali_sensor_address.line}"
f" - A{self.sensor.data.dali_sensor_address.address}\u00b2"
),
via_device=(DOMAIN, str(self._config_entry_unique_id)),
)
self._attr_device_info = device_info
@property
def sensor(self) -> Sensor:
"""Return the sensor data."""
return self.coordinator.data[self._sensor_id]
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._sensor_id in self.coordinator.data
@property
def native_value(self) -> float | None:
"""Return the measurement value of the sensor."""
return self.sensor.data.value
+21 -7
View File
@@ -42,6 +42,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.frame import ReportBehavior, report_usage
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
@@ -124,8 +125,8 @@ def publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int | None = 0,
retain: bool | None = False,
qos: int = 0,
retain: bool = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -136,8 +137,8 @@ async def async_publish(
hass: HomeAssistant,
topic: str,
payload: PublishPayloadType,
qos: int | None = 0,
retain: bool | None = False,
qos: int = 0,
retain: bool = False,
encoding: str | None = DEFAULT_ENCODING,
) -> None:
"""Publish message to a MQTT topic."""
@@ -177,9 +178,22 @@ async def async_publish(
)
return
await mqtt_data.client.async_publish(
topic, outgoing_payload, qos or 0, retain or False
)
# Passing None for qos or retain args was deprecated.
# Custom integrations should update there code.
# Check for fallback to `None` values can be removed with HA Core 2027.6
if qos is None or retain is None:
report_usage( # type: ignore[unreachable]
"that calls the MQTT publish API with `None` for qos or retain. "
"The `qos` argument must be an `int`, "
"and the `retain` argument must be a `bool`",
breaks_in_ha_version="2027.6.0",
core_behavior=ReportBehavior.LOG,
exclude_integrations={DOMAIN},
)
qos = qos or 0
retain = retain or False
await mqtt_data.client.async_publish(topic, outgoing_payload, qos, retain)
@callback
@@ -6,12 +6,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_NAME,
CONF_IP_ADDRESS,
CONF_MAC,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.util import dt as dt_util
from .const import (
@@ -81,9 +83,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
entry.runtime_data = hub
device_registry = dr.async_get(hass)
connections: set[tuple[str, str]] = set()
if mac := entry.data.get(CONF_MAC):
connections.add((CONNECTION_NETWORK_MAC, format_mac(mac)))
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, hub.hub_serial)},
connections=connections,
serial_number=hub.hub_serial,
name=hub.hub_info[ATTR_NAME],
manufacturer=NOBO_MANUFACTURER,
+1 -6
View File
@@ -99,12 +99,7 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
self._read_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target HVAC mode, if it's supported."""
if hvac_mode not in self.hvac_modes:
raise ValueError(
f"Zone {self._id} '{self._attr_name}' called with unsupported HVAC mode"
f" '{hvac_mode}'"
)
"""Set new target HVAC mode."""
if hvac_mode == HVACMode.AUTO:
await self.async_set_preset_mode(PRESET_NONE)
elif hvac_mode == HVACMode.HEAT:
@@ -11,10 +11,12 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlowWithReload,
)
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import NoboHubConfigEntry
from .const import (
@@ -39,6 +41,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovered_hubs: dict[str, Any] | None = None
self._hub: str | None = None
self._mac: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -69,6 +72,83 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=data_schema,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of a Nobø Ecohub.
The MAC from the DHCP packet is set as the flow's temporary
unique_id so the user can dismiss this discovery via "Ignore",
and so a previously-ignored hub aborts cleanly on rediscovery.
The unique_id is replaced with the full 12-digit serial when an
entry is created.
Four paths from here:
- Fast path: a configured entry already has this MAC stored
refresh its IP and abort.
- Device is already ignored.
- IP+prefix match: listen for the hub's UDP broadcast (15s) to
learn the 9-digit serial prefix. If a configured entry's
stored IP and prefix both match the DHCP packet, backfill its
MAC and abort.
- Otherwise: route to the `selected` step so the user can
supply the 3-digit serial suffix.
"""
self._mac = discovery_info.macaddress
# Fast path: a configured entry already knows this MAC. Refresh
# its IP and skip the broadcast wait entirely. Done before
# `async_set_unique_id` so an ignored entry with the same MAC
# doesn't block the IP refresh of an active configuration.
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_MAC) == discovery_info.macaddress:
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_IP_ADDRESS: discovery_info.ip},
reason="already_configured",
)
# Use the MAC as the temporary unique_id so the frontend offers an
# "Ignore" option, and so a previously-ignored MAC correctly aborts
# the flow here. The MAC is per-device unique (the 9-digit serial
# prefix would shadow sibling hubs from the same production batch).
# Replaced with the full 12-digit serial in _create_configuration
# once the user supplies the suffix.
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
self._abort_if_unique_id_configured()
# Wait 15s — when DHCP fires on hub boot, the hub's broadcast
# service comes up after the DHCPDISCOVER but typically within
# ~10s. Shorter waits may miss the first post-boot broadcast.
discovered = await nobo.async_discover_hubs(
ip=discovery_info.ip, autodiscover_wait=15.0
)
if not discovered:
return self.async_abort(reason="cannot_discover")
_, serial_prefix = next(iter(discovered))
# Fallback: a configured entry without a stored MAC (manual or
# user-picker entry, not yet DHCP-backfilled) is identified by
# both the stored IP and the 9-digit serial prefix matching the
# DHCP packet. Requiring IP match prevents clobbering a sibling
# entry from the same production batch (which shares the prefix).
# Pynobo's connection-failure rediscovery handles IP changes for
# non-DHCP-backfilled entries.
for entry in self._async_current_entries(include_ignore=False):
if (
entry.data.get(CONF_IP_ADDRESS) == discovery_info.ip
and entry.unique_id
and entry.unique_id.startswith(serial_prefix)
):
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_MAC: discovery_info.macaddress},
reason="already_configured",
)
self._discovered_hubs = {discovery_info.ip: serial_prefix}
self._hub = discovery_info.ip
return await self.async_step_selected()
async def async_step_selected(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -140,6 +220,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN):
data={
CONF_SERIAL: serial,
CONF_IP_ADDRESS: ip_address,
CONF_MAC: self._mac,
},
)
@@ -3,6 +3,12 @@
"name": "Nob\u00f8 Ecohub",
"codeowners": ["@echoromeo", "@oyvindwe"],
"config_flow": true,
"dhcp": [
{
"hostname": "hub*",
"macaddress": "7C8306*"
}
],
"documentation": "https://www.home-assistant.io/integrations/nobo_hub",
"integration_type": "hub",
"iot_class": "local_push",
+2 -4
View File
@@ -43,6 +43,7 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_suggested_display_precision = 1
def __init__(self, serial: str, hub: nobo) -> None:
"""Initialize the temperature sensor."""
@@ -75,7 +76,4 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
return
self._attr_available = True
value = self._nobo.get_current_component_temperature(self._id)
if value is None:
self._attr_native_value = None
else:
self._attr_native_value = round(float(value), 1)
self._attr_native_value = None if value is None else float(value)
@@ -1,7 +1,9 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_discover": "Could not detect a Nobø Ecohub at the discovered IP address."
},
"error": {
"cannot_connect": "Failed to connect - check serial number",
+26 -14
View File
@@ -36,6 +36,22 @@ if TYPE_CHECKING:
from . import NordPoolConfigEntry
from .const import ATTR_RESOLUTION, DOMAIN
def _validate_areas(areas: list[str]) -> list[str]:
"""Validate the areas."""
validated_areas: list[str] = []
for area in areas:
validated_area = cv.string(area)
validated_area = validated_area.upper()
if validated_area not in AREAS:
raise vol.Invalid(f"Area {area} is not valid")
validated_areas.append(validated_area)
return validated_areas
_LOGGER = logging.getLogger(__name__)
ATTR_CONFIG_ENTRY = "config_entry"
ATTR_AREAS = "areas"
@@ -47,9 +63,11 @@ SERVICE_GET_PRICES_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Required(ATTR_DATE): cv.date,
vol.Optional(ATTR_AREAS): vol.All(vol.In(list(AREAS)), cv.ensure_list, [str]),
vol.Optional(ATTR_AREAS, default=[]): vol.All(cv.ensure_list, _validate_areas),
vol.Optional(ATTR_CURRENCY): vol.All(
cv.string, vol.In([currency.value for currency in Currency])
cv.string,
vol.Upper,
vol.In([currency.value for currency in Currency]),
),
}
)
@@ -76,20 +94,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
client = entry.runtime_data.client
asked_date: date = call.data[ATTR_DATE]
areas: list[str] = entry.data[ATTR_AREAS]
if _areas := call.data.get(ATTR_AREAS):
areas = _areas
areas = call.data.get(ATTR_AREAS)
areas = areas or entry.data[ATTR_AREAS]
currency: str = entry.data[ATTR_CURRENCY]
if _currency := call.data.get(ATTR_CURRENCY):
currency = _currency
currency = call.data.get(ATTR_CURRENCY)
currency = currency or entry.data[ATTR_CURRENCY]
resolution: int = 60
if _resolution := call.data.get(ATTR_RESOLUTION):
resolution = _resolution
areas = [area.upper() for area in areas]
currency = currency.upper()
resolution = call.data.get(ATTR_RESOLUTION)
resolution = resolution or 60
return (client, asked_date, currency, areas, resolution)
@@ -12,6 +12,7 @@ get_prices_for_date:
areas:
selector:
select:
multiple: true
options:
- "EE"
- "LT"
@@ -34,6 +35,8 @@ get_prices_for_date:
- "SE2"
- "SE3"
- "SE4"
- "BG"
- "TEL"
- "SYS"
mode: dropdown
currency:
@@ -60,6 +63,7 @@ get_price_indices_for_date:
areas:
selector:
select:
multiple: true
options:
- "EE"
- "LT"
@@ -82,6 +86,8 @@ get_price_indices_for_date:
- "SE2"
- "SE3"
- "SE4"
- "BG"
- "TEL"
- "SYS"
mode: dropdown
currency:
@@ -9,7 +9,11 @@ from homeassistant.components.radio_frequency import (
async_get_transmitters,
async_send_command,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
@@ -43,7 +47,26 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick a transmitter and code."""
"""Pick a transmitter and code for a new entry."""
return await self._async_step_picker("user", user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick a transmitter and code to update an existing entry."""
if user_input is None and self._transmitter_entity_id is None:
entry = self._get_reconfigure_entry()
transmitter = er.async_get(self.hass).async_get(
entry.data[CONF_TRANSMITTER]
)
self._transmitter_entity_id = transmitter.entity_id if transmitter else None
self._code = entry.data[CONF_CODE]
return await self._async_step_picker("reconfigure", user_input)
async def _async_step_picker(
self, step_id: str, user_input: dict[str, Any] | None
) -> ConfigFlowResult:
"""Show the transmitter+code picker shared by user and reconfigure steps."""
try:
transmitters = async_get_transmitters(self.hass, FREQUENCY, MODULATION)
except HomeAssistantError:
@@ -57,8 +80,17 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
assert entity_entry is not None
code = int(user_input[CONF_CODE])
await self.async_set_unique_id(f"{entity_entry.id}_{code}")
self._abort_if_unique_id_configured()
unique_id = f"{entity_entry.id}_{code}"
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_RECONFIGURE:
existing = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, unique_id
)
reconfigure_entry = self._get_reconfigure_entry()
if existing and existing.entry_id != reconfigure_entry.entry_id:
return self.async_abort(reason="already_configured")
else:
self._abort_if_unique_id_configured()
self._transmitter_entity_id = entity_entry.entity_id
self._transmitter_id = entity_entry.id
self._code = code
@@ -80,7 +112,7 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
),
}
return self.async_show_form(
step_id="user",
step_id=step_id,
data_schema=vol.Schema(schema),
)
@@ -117,18 +149,21 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_retry(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Return to the code selection step."""
"""Return to the picker step matching the current source."""
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_reconfigure()
return await self.async_step_user()
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Create the config entry."""
"""Create or update the config entry."""
assert self._transmitter_id is not None
return self.async_create_entry(
title="Novy Cooker Hood",
data={
CONF_TRANSMITTER: self._transmitter_id,
CONF_CODE: self._code,
},
)
data = {CONF_TRANSMITTER: self._transmitter_id, CONF_CODE: self._code}
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=data,
unique_id=f"{self._transmitter_id}_{self._code}",
)
return self.async_create_entry(title="Novy Cooker Hood", data=data)
@@ -0,0 +1,28 @@
"""Diagnostics support for the Novy Cooker Hood integration."""
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .const import CONF_TRANSMITTER
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
registry = er.async_get(hass)
entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id)
transmitter = registry.async_get(config_entry.data[CONF_TRANSMITTER])
transmitter_state = hass.states.get(transmitter.entity_id) if transmitter else None
return {
"config_entry": config_entry.as_dict(),
"entities": [entity.extended_dict for entity in entities],
"transmitter": {
"entity_id": transmitter.entity_id if transmitter else None,
"state": transmitter_state.as_dict() if transmitter_state else None,
},
}
@@ -49,7 +49,7 @@ rules:
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: |
@@ -90,7 +90,7 @@ rules:
status: exempt
comment: |
The light entity uses the default icon for its state.
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
@@ -3,9 +3,21 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"step": {
"reconfigure": {
"data": {
"code": "[%key:component::novy_cooker_hood::config::step::user::data::code%]",
"transmitter": "[%key:component::novy_cooker_hood::config::step::user::data::transmitter%]"
},
"data_description": {
"code": "[%key:component::novy_cooker_hood::config::step::user::data_description::code%]",
"transmitter": "[%key:component::novy_cooker_hood::config::step::user::data_description::transmitter%]"
},
"description": "[%key:component::novy_cooker_hood::config::step::user::description%]"
},
"test_failed": {
"description": "Could not send the test command for code {code}. Check that your radio frequency transmitter is online, then press Retry.",
"menu_options": {
+66 -3
View File
@@ -100,14 +100,21 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command=OverkizCommand.LOWER_CLOSE,
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to remove open/close commands
# Needs override to add support for very specific tilt commands
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.TILT_ONLY_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
# Position commands fully open/close the tilt
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
# Tilt commands move the tilt with a few degrees
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(1, 0),
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(1, 0),
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:ExteriorVenetianBlindRTSComponent)
@@ -124,6 +131,57 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override to support very specific tilt commands (rts:VenetianBlindRTSComponent)
# uiClass is VenetianBlind
OverkizCoverDescription(
key=UIWidget.UP_DOWN_VENETIAN_BLIND,
device_class=CoverDeviceClass.BLIND,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
open_tilt_command=OverkizCommand.TILT_POSITIVE,
open_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
close_tilt_command=OverkizCommand.TILT_NEGATIVE,
close_tilt_command_args=(15, 1), # position (1-127), speed (1-15)
stop_tilt_command=OverkizCommand.STOP,
),
# Needs override since PositionableGarageDoor reports
# core:OpenClosedUnknownState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
),
# Needs override since PositionableGarageDoorWithPartialPosition reports
# core:OpenClosedPartialState instead of core:OpenClosedState
# uiClass is GarageDoor
OverkizCoverDescription(
key=UIWidget.POSITIONABLE_GARAGE_DOOR_WITH_PARTIAL_POSITION,
device_class=CoverDeviceClass.GARAGE,
current_position_state=OverkizState.CORE_CLOSURE,
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PARTIAL,
),
# Needs override since DiscreteGateWithPedestrianPosition reports
# core:OpenClosedPedestrianState instead of core:OpenClosedState
# uiClass is Gate
OverkizCoverDescription(
key=UIWidget.DISCRETE_GATE_WITH_PEDESTRIAN_POSITION,
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
stop_command=OverkizCommand.STOP,
),
# Needs override to support this Generic device (rts:GenericRTSComponent)
# uiClass is Generic (not mapped to cover as this is a Generic device class)
OverkizCoverDescription(
@@ -200,7 +258,7 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
set_position_command=OverkizCommand.SET_CLOSURE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_UNKNOWN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
@@ -208,12 +266,15 @@ COVER_DESCRIPTIONS: list[OverkizCoverDescription] = [
device_class=CoverDeviceClass.GATE,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
is_closed_state=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN,
is_closed_state=OverkizState.CORE_OPEN_CLOSED,
stop_command=OverkizCommand.STOP,
),
OverkizCoverDescription(
key=UIClass.PERGOLA,
device_class=CoverDeviceClass.AWNING,
open_command=OverkizCommand.OPEN,
close_command=OverkizCommand.CLOSE,
stop_command=OverkizCommand.STOP,
is_closed_state=OverkizState.CORE_SLATS_OPEN_CLOSED,
current_tilt_position_state=OverkizState.CORE_SLATE_ORIENTATION,
set_tilt_position_command=OverkizCommand.SET_ORIENTATION,
@@ -391,6 +452,8 @@ class OverkizCover(OverkizDescriptiveEntity, CoverEntity):
"""Return if the cover is closed."""
if is_closed_state := self.entity_description.is_closed_state:
if state := self.device.states.get(is_closed_state):
if state.value == OverkizCommandParam.UNKNOWN:
return None
return state.value == OverkizCommandParam.CLOSED
if (position := self.current_cover_position) is not None:
@@ -20,6 +20,8 @@ COMMANDS_WITHOUT_DELAY = [
OverkizCommand.ON,
OverkizCommand.ON_WITH_TIMER,
OverkizCommand.TEST,
OverkizCommand.TILT_POSITIVE,
OverkizCommand.TILT_NEGATIVE,
]
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.20.0"],
"requirements": ["pyoverkiz==1.20.3"],
"zeroconf": [
{
"name": "gateway*",
+17 -1
View File
@@ -8,6 +8,7 @@ from pyoverkiz.enums import OverkizAttribute, OverkizState, UIWidget
from pyoverkiz.types import StateType as OverkizStateType
from homeassistant.components.sensor import (
DEVICE_CLASS_UNITS,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -604,10 +605,25 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity):
if (unit := attrs[OverkizAttribute.CORE_MEASURED_VALUE_TYPE]) and (
unit_value := unit.value_as_str
):
return OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
ha_unit = OVERKIZ_UNIT_TO_HA.get(unit_value, default_unit)
if self._is_unit_valid_for_device_class(ha_unit):
return ha_unit
return default_unit
def _is_unit_valid_for_device_class(self, unit: str) -> bool:
"""Check if a unit is valid for this sensor's device class.
The device-level core:MeasuredValueType attribute describes the primary
sensor (e.g. luminance/temperature), but must not override the unit of
unrelated sensors on the same device (e.g. RSSI).
"""
if not (device_class := self.entity_description.device_class):
return True
if (valid_units := DEVICE_CLASS_UNITS.get(device_class)) is None:
return True
return unit in valid_units
class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity):
"""Representation of an Overkiz HomeKit Setup Code."""
@@ -0,0 +1,28 @@
"""Integration for PAJ GPS trackers."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import PajGpsConfigEntry, PajGpsCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""
pajgps_coordinator = PajGpsCoordinator(hass, entry)
await pajgps_coordinator.async_config_entry_first_refresh()
entry.runtime_data = pajgps_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: PajGpsConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,92 @@
"""Config flow for PAJ GPS Tracker integration."""
import logging
from typing import Any
from aiohttp import ClientError
from pajgps_api import PajGpsApi
from pajgps_api.models.auth import AuthResponse
from pajgps_api.pajgps_api_error import AuthenticationError, TokenRefreshError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
autocomplete="email",
)
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
class PajGPSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for PAJ GPS Tracker."""
async def _validate_credentials(
self, email: str, password: str
) -> tuple[str | None, AuthResponse | None]:
"""Attempt a real login with the given credentials.
Returns (None, auth) on success, or (error_key, None) on failure.
"""
websession = async_get_clientsession(self.hass)
try:
api = PajGpsApi(email=email, password=password, websession=websession)
auth = await api.login()
except AuthenticationError, TokenRefreshError:
return "invalid_auth", None
except ClientError:
return "cannot_connect", None
except Exception:
_LOGGER.exception("Unexpected error validating PAJ GPS credentials")
return "unknown", None
return None, auth
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
normalized_email = user_input[CONF_EMAIL].strip().lower()
user_input[CONF_EMAIL] = normalized_email
error, auth = await self._validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
if error is None and auth is not None:
await self.async_set_unique_id(str(auth.userID))
self._abort_if_unique_id_configured()
return self.async_create_entry(title=normalized_email, data=user_input)
if error is not None:
errors["base"] = error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)
@@ -0,0 +1,4 @@
"""Constants for the PajGPS integration."""
DOMAIN = "paj_gps"
UPDATE_INTERVAL = 30
@@ -0,0 +1,107 @@
"""DataUpdateCoordinator for the PAJ GPS integration."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from pajgps_api import PajGpsApi
from pajgps_api.models.device import Device
from pajgps_api.models.trackpoint import TrackPoint
from pajgps_api.pajgps_api_error import (
AuthenticationError,
PajGpsApiError,
TokenRefreshError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
type PajGpsConfigEntry = ConfigEntry[PajGpsCoordinator]
@dataclass
class PajGpsData:
"""Snapshot of all PAJ GPS data for one coordinator tick."""
devices: dict[int, Device]
positions: dict[int, TrackPoint]
class PajGpsCoordinator(DataUpdateCoordinator[PajGpsData]):
"""Coordinator for the PAJ GPS integration."""
config_entry: PajGpsConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: PajGpsConfigEntry,
) -> None:
"""Initialize the coordinator from config-entry data."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
config_entry=config_entry,
)
self._email: str = config_entry.data[CONF_EMAIL]
self._user_id: int | None = None
self.api = PajGpsApi(
email=self._email,
password=config_entry.data[CONF_PASSWORD],
websession=async_get_clientsession(hass),
)
@property
def email(self) -> str:
"""Return the account email address for this coordinator."""
return self._email
@property
def user_id(self) -> int | None:
"""Return the user ID obtained from the login response."""
return self._user_id
async def _async_setup(self) -> None:
"""Perform initial and first data refresh."""
try:
auth = await self.api.login()
self._user_id = auth.userID
except (AuthenticationError, TokenRefreshError) as exc:
raise ConfigEntryAuthFailed from exc
except Exception as exc:
raise ConfigEntryNotReady from exc
async def _async_update_data(self) -> PajGpsData:
"""Fetch device list and positions."""
devices: dict[int, Device] = {}
try:
device_list = await self.api.get_devices()
devices = {
device.id: device for device in device_list if device.id is not None
}
except PajGpsApiError as exc:
raise UpdateFailed(f"Failed to fetch device list: {exc}") from exc
device_ids = list(devices.keys())
positions: dict[int, TrackPoint] = {}
if device_ids:
try:
track_points = await self.api.get_all_last_positions(device_ids)
except PajGpsApiError as exc:
raise UpdateFailed(f"Failed to fetch positions: {exc}") from exc
positions = {
tp.iddevice: tp for tp in track_points if tp.iddevice is not None
}
return PajGpsData(devices=devices, positions=positions)
@@ -0,0 +1,78 @@
"""Platform for GPS device tracker integration.
Reads position data from PajGpsCoordinator and exposes it as a TrackerEntity.
"""
import logging
from homeassistant.components.device_tracker import SourceType
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PajGpsConfigEntry
from .coordinator import PajGpsCoordinator
from .entity import PajGpsEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PajGpsConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PAJ GPS tracker entities from a config entry."""
coordinator = config_entry.runtime_data
known_device_ids: set[int] = set()
@callback
def _async_add_new_devices() -> None:
"""Add entities for any device IDs not yet tracked."""
current_ids = set(coordinator.data.devices.keys())
new_ids = current_ids - known_device_ids
if new_ids:
sorted_new_ids = sorted(new_ids)
async_add_entities(
PajGPSDeviceTracker(coordinator, device_id)
for device_id in sorted_new_ids
)
known_device_ids.update(sorted_new_ids)
_async_add_new_devices()
if not known_device_ids:
_LOGGER.warning("No PAJ GPS devices found to add as trackers")
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
class PajGPSDeviceTracker(PajGpsEntity, TrackerEntity):
"""Tracker entity that reads position from the coordinator snapshot."""
_attr_name = None
_attr_icon = "mdi:map-marker"
def __init__(self, pajgps_coordinator: PajGpsCoordinator, device_id: int) -> None:
"""Initialize the GPS position tracker entity."""
super().__init__(pajgps_coordinator, device_id)
self._attr_unique_id = f"{pajgps_coordinator.user_id}_{device_id}"
@property
def latitude(self) -> float | None:
"""Return the latitude of the device."""
tp = self.coordinator.data.positions.get(self._device_id)
return float(tp.lat) if tp and tp.lat is not None else None
@property
def longitude(self) -> float | None:
"""Return the longitude of the device."""
tp = self.coordinator.data.positions.get(self._device_id)
return float(tp.lng) if tp and tp.lng is not None else None
@property
def source_type(self) -> SourceType:
"""Return the source type of the tracker."""
return SourceType.GPS
@@ -0,0 +1,39 @@
"""Base entity class for the PAJ GPS integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import Device, PajGpsCoordinator
class PajGpsEntity(CoordinatorEntity[PajGpsCoordinator]):
"""Base class for all PAJ GPS entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: PajGpsCoordinator, device_id: int) -> None:
"""Initialize the entity and build DeviceInfo."""
super().__init__(coordinator)
self._device_id = device_id
model = None
device_models = self.device.device_models
if device_models and isinstance(device_models[0], dict):
model = device_models[0].get("model")
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{coordinator.user_id}_{device_id}")},
name=self.device.name or f"PAJ GPS {device_id}",
manufacturer="PAJ GPS",
model=model,
)
@property
def available(self) -> bool:
"""Return False when the device has been removed from the account."""
return super().available and self._device_id in self.coordinator.data.devices
@property
def device(self) -> Device:
"""Return the device from coordinator data."""
return self.coordinator.data.devices[self._device_id]
@@ -0,0 +1,11 @@
{
"domain": "paj_gps",
"name": "PAJ GPS",
"codeowners": ["@skipperro"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/paj_gps",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pajgps-api==0.3.1"]
}

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