Compare commits

...

95 Commits

Author SHA1 Message Date
abmantis 75413dfc11 Allow targeting non-primary entities in conditions 2026-04-27 14:43:36 +01:00
Michael 0633400725 Fix feedreader tests broken by Python 3.14.3 asyncio changes (#169080) 2026-04-27 14:35:28 +01:00
Erik Montnemery 758a851b0d Add tests asserting condition features (#168881) 2026-04-27 14:35:28 +01:00
Stefan Agner ead2ff214f Keep add-on update entity in progress across post-install refresh (#168756)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:35:28 +01:00
mnaggatz db91c0eaee Return None for Velux cover position when unknown (#168566) 2026-04-27 14:35:28 +01:00
shbatm 7203f61e7a Fix Flume sensor units and device classes (#169013) 2026-04-27 14:35:28 +01:00
Simone Chemelli ed99a9c7d9 Add uptime device class to the sensor platform (#164266)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 14:35:28 +01:00
Paulus Schoutsen d8a389afe0 Add radio_frequency platform to ESPHome (#168448)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 14:35:28 +01:00
Paulus Schoutsen 6cbbc2185a Add Honeywell String Lights integration (#168450) 2026-04-27 14:35:28 +01:00
abmantis f660ddddea Add target selector tests for primary_entities_only field 2026-04-27 12:53:31 +01:00
abmantis 47579a9ac7 Merge branch 'dev' of github.com:home-assistant/core into non_primary_entity_trigger 2026-04-24 15:28:38 +01:00
Robert Resch dd71d6cd50 Validate local_only user for signed requests (#169066) 2026-04-24 16:27:15 +02:00
Erik Montnemery 7d494f687e Adjust compound conditions (#169054)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 16:21:13 +02:00
Paulus Schoutsen 45adc3d477 Bump rf-protocols to 2.1.0 (#169062) 2026-04-24 09:53:41 -04:00
Tomer 59766bb249 Victron GX: quality scale adjustments (#168988)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 15:45:31 +02:00
Martin d849b12bc7 Add distance device class to Ecowitt lightning distance sensors (#168995) 2026-04-24 15:41:51 +02:00
Manu 8cd2d397d1 Add data descriptions to config flow in OTP integration (#168989) 2026-04-24 15:41:18 +02:00
Jan Bouwhuis 8580a6436d Add MQTT date platform (#168998) 2026-04-24 15:36:21 +02:00
A. Gideonse 7b3b1e34fa Bump indevolt-api to 1.4.2 (#169061) 2026-04-24 15:24:45 +02:00
Maciej Bieniek bb9520856f Bump aiotractive to 1.0.3 (#169059) 2026-04-24 15:14:17 +02:00
Paulus Schoutsen 032dce20b1 Bump aioesphomeapi to 44.21.0 (#169056) 2026-04-24 08:14:12 -05:00
Erik Montnemery a92277b7fa Add method Script.unload (#169036) 2026-04-24 15:12:17 +02:00
Willem-Jan van Rootselaar 10d78d280a Add multiple heating system circuit support to BSBlan (#165992)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 15:12:09 +02:00
Renat Sibgatulin cf1faf3a20 Refactor AirQ config flow tests (#169053) 2026-04-24 15:11:29 +02:00
Ariel Ebersberger ccd82e6b8b Disable sonos tests broken by Python 3.14.3 asyncio changes (#169046)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:59:58 +02:00
Erik Montnemery db01b8e421 Migrate async_conditions_from_config to ConditionChecker (#169033) 2026-04-24 14:28:10 +02:00
A. Gideonse bf36c3d193 Bump indevolt-api to 1.4.0 (#169050) 2026-04-24 13:20:19 +02:00
Paulus Schoutsen dd2a90a31f Add radio_frequency entity integration (#168447)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-04-24 06:37:28 -04:00
Mattie eb42804871 Add binary sensor platform to Qube heat pump (#166611)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-24 12:30:49 +02:00
Erik Montnemery 6b5bbede52 Update websocket_api.handle_test_condition to use modern condition API (#169029) 2026-04-24 12:25:39 +02:00
Marc Mueller 28c3ca37b9 Refactor pylint plugins to use match statements (#168894) 2026-04-24 12:25:16 +02:00
Franck Nijhof 76376d6b26 Add pylint plugin to detect IP-based unique IDs in config entries (#168822)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: frenck <195327+frenck@users.noreply.github.com>
2026-04-24 10:59:59 +02:00
Raphael Hehl dbb750a583 Add AV1 support for HLS fallback (#161492)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-24 10:07:26 +02:00
Trendafil Gechev aec8d00c95 Add WLED segment freeze support (#168424) 2026-04-24 10:06:49 +02:00
A. Gideonse 39fbd2ccbd Bump indevolt-api to 1.3.1 (#168986) 2026-04-24 09:58:59 +02:00
Denis Shulyaka 1942f12e55 Refactor Anthropic model args (#169014) 2026-04-24 09:58:16 +02:00
Manu eb825796f9 Remove name from config flow of Notifications for Android TV /Fire TV (#169024) 2026-04-24 09:47:03 +02:00
Maciej Bieniek ac6e425748 Add tilt and rotation binary sensors for Shelly Cury (#169002)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 09:45:13 +02:00
Øyvind Matheson Wergeland cf092c63c0 Declare PARALLEL_UPDATES = 0 for nobo_hub platforms (#169011) 2026-04-24 09:44:54 +02:00
Daniel Hjelseth Høyer 4d8f3dfaf7 Update Tibber library, 0.37.2 (#169027) 2026-04-24 09:44:43 +02:00
Erik Montnemery ed7f2b1810 Migrate compound conditions to ConditionChecker (#169028) 2026-04-24 09:44:28 +02:00
Raphael Hehl 3ff2b4424f Bump uiprotect to 10.3.1 (#169031) 2026-04-24 09:44:08 +02:00
Franck Nijhof f8e6137d28 Update fumis to v0.3.0 (#168984) 2026-04-24 09:24:05 +02:00
Abílio Costa 6a57382eff Allow extracting non-primary entities in websocket command (#168860)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 09:13:51 +02:00
Erik Montnemery cebe4aa685 Refactor condition API (#168815)
Co-authored-by: Artur Pragacz <artur@pragacz.com>
2026-04-24 07:46:34 +02:00
Ronald van der Meer 32b9a21294 Bump python-duco-client to 0.3.6 (#169020) 2026-04-24 05:18:55 +02:00
Tomer 7de684d47b Victron GX: Add reconfiguration flow (#168997)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:35:30 +02:00
Samuel Xiao 5a9bb972d0 Add sensor description for Lock state in Switchbot Cloud (#168607)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-23 23:34:47 +02:00
Øyvind Matheson Wergeland e1a73fbeed Add select platform tests for nobo_hub (#168738)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:21:26 +02:00
Tom Matheussen 20a88eb21e Add entity availability to Satel Integra (#168476)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 23:20:00 +02:00
Raphael Hehl 0bb678cacf Bump uiprotect to 10.3.0 (#168992)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-23 22:18:18 +01:00
Harvey 0e817c5c90 Bump HueBLE to 2.2.2 (#167677) 2026-04-23 22:48:44 +02:00
fender4645 e5cd1e2830 Tessie: log warning instead of raising UpdateFailed for missing energy history (#168068)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 22:47:15 +02:00
kostavelikov b4c8452a5a Add open (unlatch) support to Homee locks (#168532) 2026-04-23 22:32:51 +02:00
Tomer 86ffb9eccb Victron GX: Add exception translations (#168762)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:30:47 +02:00
epenet 7bf3e75bc8 Fix invalid notification/event handling in Tuya tests (#168854)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:14:20 +02:00
Tomer 5394c764b4 Victron GX: Add strict typing (#168907)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:11:33 +02:00
Tomer 1cd34e8477 Victron GX stale devices (#168706)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-23 22:06:13 +02:00
Willem-Jan van Rootselaar 0122b2811a Bump python-bsblan to 5.2.0 (#168892) 2026-04-23 22:05:58 +02:00
Artur Pragacz 3f2bc45686 Migrate to entity services in monoprice (#168972) 2026-04-23 22:05:20 +02:00
Franck Nijhof 4612a72cd2 Add reconfiguration flow to Fumis integration (#168759)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:53:40 +02:00
Michael 8448ace289 Migrate shopping_list to use entry.runtime_data (#168911) 2026-04-23 21:30:11 +02:00
Ariel Ebersberger 19fd6e2036 Fix b&o race conditions for Python 3.14.3 (#168885) 2026-04-23 21:26:54 +02:00
c0ffeeca7 94ca503f71 Media browser: apply sentence-style capitalization (#168971) 2026-04-23 21:24:25 +02:00
Artur Pragacz fbf30e64a0 Migrate to entity services in amcrest (#168974) 2026-04-23 21:23:43 +02:00
Jan Bouwhuis 49022b69b0 Add MQTT time platform (#168898) 2026-04-23 21:12:28 +02:00
Mick Vleeshouwer 13105bd0b7 Migrate cover platform to entity descriptions in Overkiz (#141330)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 19:58:17 +02:00
abmantis c65c502e2f Allow targeting non-primary entities in triggers 2026-04-23 17:59:56 +01:00
epenet 438c1e9c3d Remove leftover hass.data[DOMAIN] usage from insteon (#168880)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:22:44 +02:00
Christophe Gagnier b0ecc2f36a Add reconfigure flow to TechnoVE integration (#168466)
Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com>
2026-04-23 15:09:35 +02:00
Ronald van der Meer 19f19e00f6 Add UCRH sensor support and warn on unknown node types in Duco (#168758) 2026-04-23 15:02:36 +02:00
Raphael Hehl 95ec39ac1a unifi_access: add direction attribute to access events (#168853)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-04-23 14:44:57 +02:00
Matthias Alphart c6b4594e7a Update knx-frontend to 2026.4.22.141111 (#168837) 2026-04-23 14:44:24 +02:00
Joost Lekkerkerker cf0b5c6e51 Migrate GitHub to subentries (#160564)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-04-23 14:23:47 +02:00
epenet 187fcd10b3 Add async_panel_exists helper to frontend and use it across integrations (#168884) 2026-04-23 14:14:45 +03:00
TheJulianJES ed1cba02ae Migrate Matter integration to use runtime_data (#168862) 2026-04-23 13:03:08 +02:00
epenet b213eb23c8 Reduce context switching in Tuya initialisation (#168830)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 12:48:15 +02:00
renovate[bot] 30d362dc8e Update uv to 0.11.7 (#168864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-23 10:43:51 +02:00
Erik Montnemery 67c818c7a8 Add comment to trigger base class (#168882) 2026-04-23 10:42:07 +02:00
epenet 5927f50bd2 Use runtime_data in Huawei LTE (#168876)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:48:45 +02:00
epenet 66d7afa442 Migrate flux_led to use HassKey for FLUX_LED_DISCOVERY (#168872)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:56:20 +02:00
epenet 51fcdaff7a Migrate slimproto to use runtime_data (#168869)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:55:37 +02:00
Raphael Hehl 67baec27cf unifi_access: add missing WebSocket handlers for remote_view and device_update events (#168850)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-23 08:50:09 +02:00
epenet d45941d648 Migrate kraken to use runtime_data (#168870)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:24:56 +02:00
Raphael Hehl a338d04441 unifi_access: bump py-unifi-access to 1.3.0 (#168851)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-23 08:24:41 +02:00
epenet 69eca62446 Clean up leftover hass.data[DOMAIN] usage in keenetic_ndms2 (#168871)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 08:20:48 +02:00
abmantis 13e28210aa Allow extracting non-primary entities in websocket command 2026-04-22 23:19:29 +01:00
Franck Nijhof 507b5f1bbf Add pylint plugin to detect polling interval fields in config flows (#168849) 2026-04-22 23:41:43 +02:00
A. Gideonse ee8a15b368 Fix incorrect sensor definition for Indevolt Gen-1 devices (#168835)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-04-22 22:03:13 +02:00
Erik Montnemery 7f92d88606 Replace climate-control device with thermostat in climate translations (#161419) 2026-04-22 21:02:54 +02:00
epenet cc1c5e788f Revert Tuya camera quirk changes (#168820) 2026-04-22 20:54:49 +02:00
epenet 1159946391 Bump tuya-device-handlers to 0.0.18 (#168821) 2026-04-22 20:53:37 +02:00
Erik Montnemery 46208c034e Add tests asserting air_quality condition features (#168731) 2026-04-22 20:42:42 +02:00
puddly abdd132bdc Register optimized ESPHome serial proxy transport with serialx (#168817) 2026-04-22 13:16:56 -04:00
Denis Shulyaka 1b71ef2a60 Add gpt-image-2 model support for OpenAI (#168826) 2026-04-22 18:13:04 +01:00
403 changed files with 16248 additions and 3328 deletions
+1
View File
@@ -36,6 +36,7 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
+1
View File
@@ -599,6 +599,7 @@ homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
Generated
+4
View File
@@ -758,6 +758,8 @@ CLAUDE.md @home-assistant/core
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
@@ -1415,6 +1417,8 @@ CLAUDE.md @home-assistant/core
/tests/components/radarr/ @tkdrob
/homeassistant/components/radio_browser/ @frenck
/tests/components/radio_browser/ @frenck
/homeassistant/components/radio_frequency/ @home-assistant/core
/tests/components/radio_frequency/ @home-assistant/core
/homeassistant/components/radiotherm/ @vinnyfuria
/tests/components/radiotherm/ @vinnyfuria
/homeassistant/components/rainbird/ @konikvranik @allenporter
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell"]
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
}
@@ -25,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI Generated Images",
"AI generated images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
@@ -36,6 +36,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
+1 -2
View File
@@ -39,7 +39,6 @@ from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
from .camera import STREAM_SOURCE_LIST
from .const import (
CAMERAS,
COMM_RETRIES,
COMM_TIMEOUT,
DATA_AMCREST,
@@ -359,7 +358,7 @@ def _start_event_monitor(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amcrest IP Camera component."""
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}})
for device in config[DOMAIN]:
name: str = device[CONF_NAME]
+10 -74
View File
@@ -12,13 +12,11 @@ import aiohttp
from aiohttp import web
from amcrest import AmcrestError
from haffmpeg.camera import CameraMjpeg
import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_stream,
async_aiohttp_proxy_web,
@@ -29,11 +27,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
ATTR_COLOR_BW,
CAMERA_WEB_SESSION_TIMEOUT,
CAMERAS,
CBW,
COMM_TIMEOUT,
DATA_AMCREST,
DEVICES,
MOV,
RESOLUTION_TO_STREAM,
SERVICE_UPDATE,
SNAPSHOT_TIMEOUT,
@@ -49,65 +49,11 @@ SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
_ATTR_PTZ_TT = "travel_time"
_ATTR_PTZ_MOV = "movement"
_MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"]
_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"]
_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"]
_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS
_DEFAULT_TT = 0.2
_ATTR_PRESET = "preset"
_ATTR_COLOR_BW = "color_bw"
_CBW_COLOR = "color"
_CBW_AUTO = "auto"
_CBW_BW = "bw"
_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids})
_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend(
{vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}
)
_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)})
_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
{
vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
}
)
CAMERA_SERVICES = {
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
"ptz_control": (
_SRV_PTZ_SCHEMA,
"async_ptz_control",
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
),
}
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
@@ -275,7 +221,7 @@ class AmcrestCam(Camera):
self._motion_recording_enabled
)
if self._color_bw is not None:
attr[_ATTR_COLOR_BW] = self._color_bw
attr[ATTR_COLOR_BW] = self._color_bw
return attr
@property
@@ -322,15 +268,7 @@ class AmcrestCam(Camera):
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self) -> None:
"""Subscribe to signals and add camera to list."""
self._unsub_dispatcher.extend(
async_dispatcher_connect(
self.hass,
service_signal(service, self.entity_id),
getattr(self, callback_name),
)
for service, (_, callback_name, _) in CAMERA_SERVICES.items()
)
"""Subscribe to signals."""
self._unsub_dispatcher.append(
async_dispatcher_connect(
self.hass,
@@ -338,11 +276,9 @@ class AmcrestCam(Camera):
self.async_on_demand_update,
)
)
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Remove camera from list and disconnect from signals."""
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
"""Disconnect from signals."""
for unsub_dispatcher in self._unsub_dispatcher:
unsub_dispatcher()
@@ -456,7 +392,7 @@ class AmcrestCam(Camera):
async def async_ptz_control(self, movement: str, travel_time: float) -> None:
"""Move or zoom camera in specified direction."""
code = _ACTION[_MOV.index(movement)]
code = _ACTION[MOV.index(movement)]
kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0}
if code in _MOVE_1_ACTIONS:
@@ -613,10 +549,10 @@ class AmcrestCam(Camera):
)
async def _async_get_color_mode(self) -> str:
return _CBW[await self._api.async_day_night_color]
return CBW[await self._api.async_day_night_color]
async def _async_set_color_mode(self, cbw: str) -> None:
await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0)
await self._api.async_set_day_night_color(CBW.index(cbw), channel=0)
async def _async_set_color_bw(self, cbw: str) -> None:
"""Set camera color mode."""
+15 -1
View File
@@ -2,7 +2,6 @@
DOMAIN = "amcrest"
DATA_AMCREST = DOMAIN
CAMERAS = "cameras"
DEVICES = "devices"
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
@@ -17,3 +16,18 @@ SERVICE_UPDATE = "update"
RESOLUTION_LIST = {"high": 0, "low": 1}
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
ATTR_COLOR_BW = "color_bw"
CBW = ["color", "auto", "bw"]
MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
+57 -52
View File
@@ -1,62 +1,67 @@
"""Support for Amcrest IP cameras."""
"""Services for Amcrest IP cameras."""
from __future__ import annotations
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import async_extract_entity_ids
import voluptuous as vol
from .camera import CAMERA_SERVICES
from .const import CAMERAS, DATA_AMCREST, DOMAIN
from .helpers import service_signal
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV
_ATTR_PRESET = "preset"
_ATTR_PTZ_MOV = "movement"
_ATTR_PTZ_TT = "travel_time"
_DEFAULT_TT = 0.2
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Amcrest IP Camera services."""
for service_name, func in (
("enable_recording", "async_enable_recording"),
("disable_recording", "async_disable_recording"),
("enable_audio", "async_enable_audio"),
("disable_audio", "async_disable_audio"),
("enable_motion_recording", "async_enable_motion_recording"),
("disable_motion_recording", "async_disable_motion_recording"),
("start_tour", "async_start_tour"),
("stop_tour", "async_stop_tour"),
):
service.async_register_platform_entity_service(
hass,
DOMAIN,
service_name,
entity_domain=CAMERA_DOMAIN,
schema=None,
func=func,
)
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
service.async_register_platform_entity_service(
hass,
DOMAIN,
"goto_preset",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))},
func="async_goto_preset",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_color_bw",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)},
func="async_set_color_bw",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"ptz_control",
entity_domain=CAMERA_DOMAIN,
schema={
vol.Required(_ATTR_PTZ_MOV): vol.In(MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
},
func="async_ptz_control",
)
+33 -36
View File
@@ -703,15 +703,14 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log( # noqa: C901
async def _get_model_args( # noqa: C901
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
) -> tuple[MessageCreateParamsStreaming, str | None]:
"""Get the model arguments."""
options: dict[str, Any] = DEFAULT | self.subentry.data
preloaded_tools = [
"HassTurnOn",
@@ -729,21 +728,18 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
messages, container_id = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
model = options[CONF_CHAT_MODEL]
model_args = MessageCreateParamsStreaming(
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
max_tokens=options[CONF_MAX_TOKENS],
system=system.content,
stream=True,
container=container_id,
)
if (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.PROMPT
):
if options[CONF_PROMPT_CACHING] == PromptCaching.PROMPT:
model_args["system"] = [
{
"type": "text",
@@ -751,19 +747,14 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"cache_control": {"type": "ephemeral"},
}
]
elif (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.AUTOMATIC
):
elif options[CONF_PROMPT_CACHING] == PromptCaching.AUTOMATIC:
model_args["cache_control"] = {"type": "ephemeral"}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.adaptive.supported
):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
thinking_effort = options[CONF_THINKING_EFFORT]
if thinking_effort != "none":
model_args["thinking"] = ThinkingConfigAdaptiveParam(
type="adaptive", display="summarized"
@@ -772,9 +763,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
else:
thinking_budget = options.get(
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
)
thinking_budget = options[CONF_THINKING_BUDGET]
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.enabled.supported
@@ -791,9 +780,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
and self.model_info.capabilities.effort.supported
):
model_args["output_config"] = OutputConfigParam(
effort=options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
effort=options[CONF_THINKING_EFFORT]
)
tools: list[ToolUnionParam] = []
@@ -803,12 +790,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
for tool in chat_log.llm_api.tools
]
if options.get(CONF_CODE_EXECUTION):
if options[CONF_CODE_EXECUTION]:
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_WEB_SEARCH)
or not options[CONF_WEB_SEARCH]
):
tools.append(
CodeExecutionTool20250825Param(
@@ -817,26 +804,26 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
),
)
if options.get(CONF_WEB_SEARCH):
if options[CONF_WEB_SEARCH]:
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_CODE_EXECUTION)
or not options[CONF_CODE_EXECUTION]
):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
if options[CONF_WEB_SEARCH_USER_LOCATION]:
web_search["user_location"] = {
"type": "approximate",
"city": options.get(CONF_WEB_SEARCH_CITY, ""),
@@ -937,10 +924,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
preloaded_tools.append(structure_name)
if tools:
if (
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
and len(tools) > len(preloaded_tools) + 1
):
if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1:
for tool in tools:
if not tool["name"].endswith(tuple(preloaded_tools)):
tool["defer_loading"] = True
@@ -953,6 +937,19 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model_args["tools"] = tools
return model_args, structure_name
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
model_args, structure_name = await self._get_model_args(
chat_log, structure_name, structure
)
coordinator = self.entry.runtime_data
client = coordinator.client
@@ -974,7 +971,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
]
)
messages.extend(new_messages)
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
except anthropic.AuthenticationError as err:
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
@@ -21,8 +21,9 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
from .const import DOMAIN, MANUFACTURER, BeoModel
from .services import async_setup_services
from .util import get_remotes
from .websocket import BeoWebsocket
@@ -58,15 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
# Remove casts to str
assert entry.unique_id
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
name=entry.title,
model=entry.data[CONF_MODEL],
)
client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context())
# Check API and WebSocket connection
@@ -83,6 +75,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
model=entry.data[CONF_MODEL],
)
# Create devices for paired Beoremote One remotes
for remote in await get_remotes(client):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, entry.unique_id),
)
websocket = BeoWebsocket(hass, entry, client)
# Add the websocket and API client
@@ -52,6 +52,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
_beolink_jid = ""
_client: MozartClient
_friendly_name = ""
_host = ""
_model = ""
_name = ""
@@ -111,6 +112,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._beolink_jid = beolink_self.jid
self._friendly_name = beolink_self.friendly_name
self._serial_number = get_serial_number_from_jid(beolink_self.jid)
await self.async_set_unique_id(self._serial_number)
@@ -149,6 +151,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="invalid_address")
self._model = discovery_info.hostname[:-16].replace("-", " ")
self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME]
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"
@@ -164,16 +167,13 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry for a discovered or manually configured Bang & Olufsen device."""
# Ensure that created entities have a unique and easily identifiable id and not a "friendly name"
self._name = f"{self._model}-{self._serial_number}"
return self.async_create_entry(
title=self._name,
title=self._friendly_name,
data=EntryData(
host=self._host,
jid=self._beolink_jid,
model=self._model,
name=self._name,
name=self._friendly_name,
),
)
@@ -20,7 +20,6 @@ from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
MANUFACTURER,
BeoModel,
WebsocketNotification,
)
@@ -142,12 +141,6 @@ class BeoRemoteKeyEvent(BeoEvent):
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, self._unique_id),
)
# Make the native key name Home Assistant compatible
@@ -115,7 +115,7 @@ class BeoSensorRemoteBatteryLevel(BeoSensor):
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
)
self._attr_native_value = remote.battery_level
self._remote = remote
+19 -5
View File
@@ -30,19 +30,33 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
BATTERY_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_ON,
support_duration=True,
primary_entities_only=False,
),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS,
STATE_OFF,
support_duration=True,
primary_entities_only=False,
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
BATTERY_PERCENTAGE_DOMAIN_SPECS,
PERCENTAGE,
primary_entities_only=False,
),
}
@@ -3,6 +3,7 @@
entity:
- domain: binary_sensor
device_class: battery
primary_entities_only: false
fields:
behavior: &condition_behavior
required: true
@@ -42,6 +43,7 @@ is_charging:
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
fields:
behavior: *condition_behavior
for: *condition_for
@@ -51,6 +53,7 @@ is_not_charging:
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
fields:
behavior: *condition_behavior
for: *condition_for
@@ -60,6 +63,7 @@ is_level:
entity:
- domain: sensor
device_class: battery
primary_entities_only: false
fields:
behavior: *condition_behavior
threshold:
+14 -6
View File
@@ -32,19 +32,27 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
),
"not_low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
),
}
@@ -33,16 +33,19 @@
entity:
- domain: binary_sensor
device_class: battery
primary_entities_only: false
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
primary_entities_only: false
low:
fields:
+131 -15
View File
@@ -13,6 +13,7 @@ from bsblan import (
Info,
StaticState,
)
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -28,11 +29,16 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN, LOGGER
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -52,7 +58,35 @@ class BSBLanData:
client: BSBLAN
device: Device
info: Info
static: StaticState | None
static: dict[int, StaticState | None]
available_circuits: list[int]
def get_bsblan_device_info(
device: Device, info: Info, host: str, port: int
) -> DeviceInfo:
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
info.device_identification.value
if info.device_identification and info.device_identification.value
else None
),
model_id=(
f"{info.controller_family.value}_{info.controller_variant.value}"
if info.controller_family
and info.controller_variant
and info.controller_family.value
and info.controller_variant.value
else None
),
sw_version=device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -75,13 +109,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# create BSBLAN client
session = async_get_clientsession(hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
await bsblan.initialize()
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
bsblan.device(),
@@ -110,18 +148,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
translation_key="setup_general_error",
) from err
try:
static = await bsblan.static_values()
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s: %s",
entry.data[CONF_HOST],
err,
)
static = None
# Fetch static values per configured circuit.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
# Static values are optional — some devices may not support them.
static_per_circuit: dict[int, StaticState | None] = {}
for circuit in circuits:
try:
static_per_circuit[circuit] = await bsblan.static_values(circuit=circuit)
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s circuit %d: %s",
entry.data[CONF_HOST],
circuit,
err,
)
static_per_circuit[circuit] = None
# Create coordinators with the already-initialized client
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan, circuits)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of fast coordinator (required for entities)
@@ -137,7 +182,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
slow_coordinator=slow_coordinator,
device=device,
info=info,
static=static,
static=static_per_circuit,
available_circuits=circuits,
)
# Register main device before forwarding platforms, so sub-devices
# (heating circuits, water heater) can reference it via via_device
device_registry = dr.async_get(hass)
port = entry.data.get(CONF_PORT, DEFAULT_PORT)
main_device_info = get_bsblan_device_info(device, info, entry.data[CONF_HOST], port)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers=main_device_info["identifiers"],
connections=main_device_info["connections"],
name=main_device_info["name"],
manufacturer=main_device_info["manufacturer"],
model=main_device_info.get("model"),
model_id=main_device_info.get("model_id"),
sw_version=main_device_info.get("sw_version"),
configuration_url=main_device_info.get("configuration_url"),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -148,3 +211,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Unload BSBLAN config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Migrate old config entries to the latest schema."""
LOGGER.debug(
"Migrating BSB-LAN entry from version %s.%s",
entry.version,
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = [1]
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
port=entry.data[CONF_PORT],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
)
session = async_get_clientsession(hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
circuits = await bsblan.get_available_circuits()
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_HEATING_CIRCUITS: circuits},
minor_version=2,
)
LOGGER.debug(
"Migrated BSB-LAN entry to version %s.%s with circuits %s",
entry.version,
entry.minor_version,
circuits,
)
return True
+28 -15
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from bsblan import BSBLANError, get_hvac_action_category
from bsblan import BSBLANError, State, get_hvac_action_category
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
from .entity import BSBLanEntity
from .entity import BSBLanCircuitEntity
PARALLEL_UPDATES = 1
@@ -63,10 +63,12 @@ async def async_setup_entry(
) -> None:
"""Set up BSBLAN device based on a config entry."""
data = entry.runtime_data
async_add_entities([BSBLANClimate(data)])
async_add_entities(
BSBLANClimate(data, circuit) for circuit in data.available_circuits
)
class BSBLANClimate(BSBLanEntity, ClimateEntity):
class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_name = None
@@ -84,37 +86,50 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
def __init__(
self,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLAN climate device."""
super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
super().__init__(data.fast_coordinator, data, circuit)
self._circuit = circuit
mac = format_mac(data.device.MAC)
# Set temperature range if available, otherwise use Home Assistant defaults
if (static := data.static) is not None:
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate"
else:
self._attr_unique_id = f"{mac}-climate-{circuit}"
# Set temperature range from per-circuit static data
if (static := data.static.get(circuit)) is not None:
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
self._attr_min_temp = min_temp.value
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
self._attr_max_temp = max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property
def _circuit_state(self) -> State:
"""Return the state for this circuit."""
return self.coordinator.data.states[self._circuit]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (current_temp := self.coordinator.data.state.current_temperature) is None:
if (current_temp := self._circuit_state.current_temperature) is None:
return None
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if (target_temp := self.coordinator.data.state.target_temperature) is None:
if (target_temp := self._circuit_state.target_temperature) is None:
return None
return target_temp.value
@property
def _hvac_mode_value(self) -> int | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
if (hvac_mode := self._circuit_state.hvac_mode) is None:
return None
return hvac_mode.value
@@ -128,9 +143,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
if (
action := self.coordinator.data.state.hvac_action
) is None or action.value is None:
if (action := self._circuit_state.hvac_action) is None or action.value is None:
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())
@@ -170,7 +183,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
data[ATTR_HVAC_MODE] = 1
try:
await self.coordinator.client.thermostat(**data)
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
raise HomeAssistantError(
"An error occurred while updating the BSBLAN device",
+38 -5
View File
@@ -15,19 +15,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = [1]
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -77,7 +79,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# Try to get device info without authentication to minimize discovery popup
config = BSBLANConfig(host=self.host, port=self.port)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
device = await bsblan.device()
except BSBLANError:
@@ -123,6 +125,8 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
if not self._auth_required:
# Discover available heating circuits
await self._discover_circuits()
return self._async_create_entry()
self.passkey = user_input.get(CONF_PASSKEY)
@@ -137,6 +141,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info()
await self._discover_circuits()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
@@ -230,9 +235,12 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# it gets the unique ID from the device info when it validates credentials
self._abort_if_unique_id_mismatch()
# Rediscover circuits in case hardware changed
await self._discover_circuits()
return self.async_update_reload_and_abort(
existing_entry,
data_updates=user_input,
data_updates={**user_input, CONF_HEATING_CIRCUITS: self.circuits},
reason="reconfigure_successful",
)
@@ -316,13 +324,14 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
return self.async_create_entry(
title=format_mac(self.mac),
title="BSB-LAN",
data={
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_PASSKEY: self.passkey,
CONF_USERNAME: self.username,
CONF_PASSWORD: self.password,
CONF_HEATING_CIRCUITS: self.circuits,
},
)
@@ -340,7 +349,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
device = await bsblan.device()
retrieved_mac = device.MAC
@@ -362,3 +371,27 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PORT: self.port,
}
)
async def _discover_circuits(self) -> None:
"""Discover available heating circuits."""
config = BSBLANConfig(
host=self.host,
passkey=self.passkey,
port=self.port,
username=self.username,
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
except (
BSBLANError,
TimeoutError,
):
LOGGER.debug(
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
+1
View File
@@ -22,5 +22,6 @@ ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature"
ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_PORT: Final = 80
+12 -6
View File
@@ -49,7 +49,7 @@ DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
class BSBLanFastData:
"""BSBLan fast-polling data."""
state: State
states: dict[int, State]
sensor: Sensor
dhw: HotWaterState | None = None
@@ -94,6 +94,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
hass: HomeAssistant,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
circuits: list[int],
) -> None:
"""Initialize the BSB-LAN fast coordinator."""
super().__init__(
@@ -103,14 +104,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL_FAST,
)
self.circuits: list[int] = circuits
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-LAN device."""
states: dict[int, State] = {}
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
# Use include filtering to only fetch parameters we actually use.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
for circuit in self.circuits:
states[circuit] = await self.client.state(
include=STATE_INCLUDE, circuit=circuit
)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
except BSBLANAuthError as err:
@@ -140,7 +146,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
)
return BSBLanFastData(
state=state,
states=states,
sensor=sensor,
dhw=dhw,
)
@@ -20,13 +20,20 @@ async def async_get_config_entry_diagnostics(
"info": data.info.model_dump(),
"device": data.device.model_dump(),
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"states": {
str(circuit): state.model_dump()
for circuit, state in data.fast_coordinator.data.states.items()
},
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump()
if data.fast_coordinator.data.dhw
else None,
},
"static": data.static.model_dump() if data.static is not None else None,
"static": {
str(circuit): static.model_dump() if static is not None else None
for circuit, static in data.static.items()
},
"available_circuits": data.available_circuits,
}
# Add DHW config and schedule from slow coordinator if available
+55 -30
View File
@@ -2,17 +2,11 @@
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from . import BSBLanData, get_bsblan_device_info
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
@@ -27,28 +21,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
super().__init__(coordinator)
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
mac = data.device.MAC
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=(
data.info.device_identification.value
if data.info.device_identification
and data.info.device_identification.value
else None
),
model_id=(
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
if data.info.controller_family
and data.info.controller_variant
and data.info.controller_family.value
and data.info.controller_variant.value
else None
),
sw_version=data.device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
self._attr_device_info = get_bsblan_device_info(
data.device, data.info, host, port
)
@@ -60,6 +34,32 @@ class BSBLanEntity(BSBLanEntityBase[BSBLanFastCoordinator]):
super().__init__(coordinator, data)
class BSBLanCircuitEntity(BSBLanEntity):
"""BSBLan entity belonging to a heating circuit sub-device."""
def __init__(
self,
coordinator: BSBLanFastCoordinator,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLan circuit entity with sub-device info."""
super().__init__(coordinator, data)
mac = data.device.MAC
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")},
translation_key="heating_circuit",
translation_placeholders={"circuit": str(circuit)},
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
class BSBLanDualCoordinatorEntity(BSBLanEntity):
"""Entity that listens to both fast and slow coordinators."""
@@ -80,3 +80,28 @@ class BSBLanDualCoordinatorEntity(BSBLanEntity):
self.async_on_remove(
self.slow_coordinator.async_add_listener(self._handle_coordinator_update)
)
class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity):
"""BSBLan entity belonging to the water heater sub-device."""
def __init__(
self,
fast_coordinator: BSBLanFastCoordinator,
slow_coordinator: BSBLanSlowCoordinator,
data: BSBLanData,
) -> None:
"""Initialize BSBLan water heater sub-device entity."""
super().__init__(fast_coordinator, slow_coordinator, data)
mac = data.device.MAC
host = fast_coordinator.config_entry.data[CONF_HOST]
port = fast_coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-water-heater")},
translation_key="water_heater",
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.4"],
"requirements": ["python-bsblan==5.2.0"],
"zeroconf": [
{
"name": "bsb-lan*",
@@ -48,13 +48,10 @@ rules:
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration provides a limited number of entities, all of which are useful to users.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
@@ -66,7 +63,7 @@ rules:
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
# Platinum
async-dependency: done
@@ -79,6 +79,14 @@
}
}
},
"device": {
"heating_circuit": {
"name": "Heating circuit {circuit}"
},
"water_heater": {
"name": "Water heater"
}
},
"entity": {
"button": {
"sync_time": {
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import DOMAIN
from .entity import BSBLanDualCoordinatorEntity
from .entity import BSBLanWaterHeaterDeviceEntity
PARALLEL_UPDATES = 1
@@ -61,7 +61,7 @@ async def async_setup_entry(
async_add_entities([BSBLANWaterHeater(data)])
class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity):
"""Defines a BSBLAN water heater entity."""
_attr_name = None
+56 -56
View File
@@ -9,34 +9,34 @@
},
"conditions": {
"is_cooling": {
"description": "Tests if one or more climate-control devices are cooling.",
"description": "Tests if one or more thermostats are cooling.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
"name": "Climate-control device is cooling"
"name": "Thermostat is cooling"
},
"is_drying": {
"description": "Tests if one or more climate-control devices are drying.",
"description": "Tests if one or more thermostats are drying.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
"name": "Climate-control device is drying"
"name": "Thermostat is drying"
},
"is_heating": {
"description": "Tests if one or more climate-control devices are heating.",
"description": "Tests if one or more thermostats are heating.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
"name": "Climate-control device is heating"
"name": "Thermostat is heating"
},
"is_hvac_mode": {
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
"description": "Tests if one or more thermostats are set to a specific HVAC mode.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
@@ -46,10 +46,10 @@
"name": "Modes"
}
},
"name": "Climate-control device HVAC mode"
"name": "Thermostat HVAC mode"
},
"is_off": {
"description": "Tests if one or more climate-control devices are off.",
"description": "Tests if one or more thermostats are off.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
@@ -58,19 +58,19 @@
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Climate-control device is off"
"name": "Thermostat is off"
},
"is_on": {
"description": "Tests if one or more climate-control devices are on.",
"description": "Tests if one or more thermostats are on.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
"name": "Climate-control device is on"
"name": "Thermostat is on"
},
"target_humidity": {
"description": "Tests the humidity setpoint of one or more climate-control devices.",
"description": "Tests the humidity setpoint of one or more thermostats.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
@@ -79,10 +79,10 @@
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
"name": "Climate-control device target humidity"
"name": "Thermostat target humidity"
},
"target_temperature": {
"description": "Tests the temperature setpoint of one or more climate-control devices.",
"description": "Tests the temperature setpoint of one or more thermostats.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
@@ -91,7 +91,7 @@
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
"name": "Climate-control device target temperature"
"name": "Thermostat target temperature"
}
},
"device_automation": {
@@ -288,67 +288,67 @@
},
"services": {
"set_fan_mode": {
"description": "Sets the fan mode of a climate-control device.",
"description": "Sets the fan mode of a thermostat.",
"fields": {
"fan_mode": {
"description": "Fan operation mode.",
"name": "Fan mode"
}
},
"name": "Set climate-control device fan mode"
"name": "Set thermostat fan mode"
},
"set_humidity": {
"description": "Sets the target humidity of a climate-control device.",
"description": "Sets the target humidity of a thermostat.",
"fields": {
"humidity": {
"description": "Target humidity.",
"name": "Humidity"
}
},
"name": "Set climate-control device target humidity"
"name": "Set thermostat target humidity"
},
"set_hvac_mode": {
"description": "Sets the HVAC mode of a climate-control device.",
"description": "Sets the HVAC mode of a thermostat.",
"fields": {
"hvac_mode": {
"description": "HVAC operation mode.",
"name": "HVAC mode"
}
},
"name": "Set climate-control device HVAC mode"
"name": "Set thermostat HVAC mode"
},
"set_preset_mode": {
"description": "Sets the preset mode of a climate-control device.",
"description": "Sets the preset mode of a thermostat.",
"fields": {
"preset_mode": {
"description": "Preset mode.",
"name": "Preset mode"
}
},
"name": "Set climate-control device preset mode"
"name": "Set thermostat preset mode"
},
"set_swing_horizontal_mode": {
"description": "Sets the horizontal swing mode of a climate-control device.",
"description": "Sets the horizontal swing mode of a thermostat.",
"fields": {
"swing_horizontal_mode": {
"description": "Horizontal swing operation mode.",
"name": "Horizontal swing mode"
}
},
"name": "Set climate-control device horizontal swing mode"
"name": "Set thermostat horizontal swing mode"
},
"set_swing_mode": {
"description": "Sets the swing mode of a climate-control device.",
"description": "Sets the swing mode of a thermostat.",
"fields": {
"swing_mode": {
"description": "Swing operation mode.",
"name": "Swing mode"
}
},
"name": "Set climate-control device swing mode"
"name": "Set thermostat swing mode"
},
"set_temperature": {
"description": "Sets the target temperature of a climate-control device.",
"description": "Sets the target temperature of a thermostat.",
"fields": {
"hvac_mode": {
"description": "HVAC operation mode.",
@@ -367,25 +367,25 @@
"name": "Target temperature"
}
},
"name": "Set climate-control device target temperature"
"name": "Set thermostat target temperature"
},
"toggle": {
"description": "Toggles a climate-control device on/off.",
"name": "Toggle climate-control device"
"description": "Toggles a thermostat on/off.",
"name": "Toggle thermostat"
},
"turn_off": {
"description": "Turns off a climate-control device.",
"name": "Turn off climate-control device"
"description": "Turns off a thermostat.",
"name": "Turn off thermostat"
},
"turn_on": {
"description": "Turns on a climate-control device.",
"name": "Turn on climate-control device"
"description": "Turns on a thermostat.",
"name": "Turn on thermostat"
}
},
"title": "Climate",
"triggers": {
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"description": "Triggers after the mode of one or more thermostats changes.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -398,10 +398,10 @@
"name": "Modes"
}
},
"name": "Climate-control device mode changed"
"name": "Thermostat mode changed"
},
"started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.",
"description": "Triggers after one or more thermostats start cooling.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -410,10 +410,10 @@
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started cooling"
"name": "Thermostat started cooling"
},
"started_drying": {
"description": "Triggers after one or more climate-control devices start drying.",
"description": "Triggers after one or more thermostats start drying.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -422,10 +422,10 @@
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started drying"
"name": "Thermostat started drying"
},
"started_heating": {
"description": "Triggers after one or more climate-control devices start heating.",
"description": "Triggers after one or more thermostats start heating.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -434,19 +434,19 @@
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device started heating"
"name": "Thermostat started heating"
},
"target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"description": "Triggers after the humidity setpoint of one or more thermostats changes.",
"fields": {
"threshold": {
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
"name": "Climate-control device target humidity changed"
"name": "Thermostat target humidity changed"
},
"target_humidity_crossed_threshold": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"description": "Triggers after the humidity setpoint of one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -458,19 +458,19 @@
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
"name": "Climate-control device target humidity crossed threshold"
"name": "Thermostat target humidity crossed threshold"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"description": "Triggers after the temperature setpoint of one or more thermostats changes.",
"fields": {
"threshold": {
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
"name": "Climate-control device target temperature changed"
"name": "Thermostat target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"description": "Triggers after the temperature setpoint of one or more thermostats crosses a threshold.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -482,10 +482,10 @@
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
"name": "Climate-control device target temperature crossed threshold"
"name": "Thermostat target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"description": "Triggers after one or more thermostats turn off.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -494,10 +494,10 @@
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device turned off"
"name": "Thermostat turned off"
},
"turned_on": {
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
"description": "Triggers after one or more thermostats turn on, regardless of the mode.",
"fields": {
"behavior": {
"name": "[%key:component::climate::common::trigger_behavior_name%]"
@@ -506,7 +506,7 @@
"name": "[%key:component::climate::common::trigger_for_name%]"
}
},
"name": "Climate-control device turned on"
"name": "Thermostat turned on"
}
}
}
@@ -169,6 +169,8 @@ class OptionsFlowHandler(OptionsFlowWithReload):
data_schema = vol.Schema(
{
# Polling interval is user-configurable, which is no longer allowed
# pylint: disable-next=hass-config-flow-polling-field
vol.Optional(
CONF_SCAN_INTERVAL,
default=self.config_entry.options.get(
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckerType,
ConditionConfig,
)
@@ -54,6 +53,7 @@ class DeviceCondition(Condition):
"""Device condition."""
_config: ConfigType
_platform_checker: ConditionCheckerType
@classmethod
async def async_validate_complete_config(
@@ -87,20 +87,19 @@ class DeviceCondition(Condition):
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> ConditionChecker:
"""Test a device condition."""
async def async_setup(self) -> None:
"""Set up a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
platform_checker = platform.async_condition_from_config(
self._platform_checker = platform.async_condition_from_config(
self._hass, self._config
)
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
result = platform_checker(self._hass, variables)
return result is not False
return checker
def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool:
"""Check the condition."""
result = self._platform_checker(self._hass, variables)
return result is not False
CONDITIONS: dict[str, type[Condition]] = {
@@ -133,6 +133,8 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
):
errors["base"] = "invalid_hostname"
else:
# Uses hostname as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(hostname)
self._abort_if_unique_id_configured()
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.4"],
"requirements": ["python-duco-client==0.3.6"],
"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][]].*",
+12 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from duco.models import Node, NodeType, VentilationState
@@ -27,6 +28,8 @@ from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -79,7 +82,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda node: node.sensor.rh if node.sensor else None,
node_types=(NodeType.BSRH,),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="iaq_rh",
@@ -88,7 +91,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
node_types=(NodeType.BSRH,),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
)
@@ -144,6 +147,13 @@ async def async_setup_entry(
if node.node_id in known_nodes:
continue
known_nodes.add(node.node_id)
if node.general.node_type == NodeType.UNKNOWN:
_LOGGER.warning(
"Duco node %s (%s) has an unsupported device type and will be ignored",
node.node_id,
node.general.name,
)
continue
new_entities.extend(
DucoSensorEntity(coordinator, node, description)
for description in SENSOR_DESCRIPTIONS
@@ -213,11 +213,13 @@ ECOWITT_SENSORS_MAPPING: Final = {
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription(
key="LIGHTNING_DISTANCE_KM",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES: SensorEntityDescription(
key="LIGHTNING_DISTANCE_MILES",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILES,
state_class=SensorStateClass.MEASUREMENT,
),
+44 -2
View File
@@ -8,18 +8,24 @@ from aioesphomeapi import APIClient, APIConnectionError
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.components.usb import (
SerialDevice,
USBDevice,
async_register_serial_port_scanner,
)
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
__version__ as ha_version,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import slugify
from . import assist_satellite, dashboard, ffmpeg_proxy
from . import assist_satellite, dashboard, ffmpeg_proxy, serial_proxy
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
from .encryption_key_storage import async_get_encryption_key_storage
@@ -34,12 +40,48 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
CLIENT_INFO = f"Home Assistant {ha_version}"
@callback
def _async_scan_serial_ports(
hass: HomeAssistant,
) -> list[USBDevice | SerialDevice]:
"""Return serial-proxy ports exposed by connected ESPHome devices."""
ports: list[USBDevice | SerialDevice] = []
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
entry_data = entry.runtime_data
if not entry_data.available:
continue
device_info = entry_data.device_info
if device_info is None:
continue
ports.extend(
SerialDevice(
device=str(serial_proxy.build_url(entry.entry_id, proxy.name)),
serial_number=(
device_info.mac_address.replace(":", "") + "-" + slugify(proxy.name)
),
manufacturer=device_info.manufacturer,
description=f"{device_info.model} ({proxy.name})",
)
for proxy in device_info.serial_proxies
)
return ports
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component."""
ffmpeg_proxy.async_setup(hass)
await assist_satellite.async_setup(hass)
await dashboard.async_setup(hass)
async_setup_websocket_api(hass)
if "usb" in hass.config.components:
async_register_serial_port_scanner(hass, _async_scan_serial_ports)
serial_proxy.set_hass_loop(hass.loop)
return True
@@ -35,6 +35,7 @@ from aioesphomeapi import (
MediaPlayerInfo,
MediaPlayerSupportedFormat,
NumberInfo,
RadioFrequencyInfo,
SelectInfo,
SensorInfo,
SensorState,
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
NumberInfo: Platform.NUMBER,
@@ -1,7 +1,7 @@
{
"domain": "esphome",
"name": "ESPHome",
"after_dependencies": ["hassio", "zeroconf", "tag"],
"after_dependencies": ["hassio", "tag", "usb", "zeroconf"],
"codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.18.0",
"aioesphomeapi==44.21.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
@@ -0,0 +1,77 @@
"""Radio Frequency platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import (
EntityState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
ModulationType.OOK: RadioFrequencyModulation.OOK,
}
class EsphomeRadioFrequencyEntity(
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
):
"""ESPHome radio frequency entity using native API."""
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges from device info."""
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
timings = command.get_raw_timings()
_LOGGER.debug("Sending RF command: %s", timings)
self._client.radio_frequency_transmit_raw_timings(
self._static_info.key,
frequency=command.frequency,
timings=timings,
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
# In ESPHome, repeat_count is total number of times to send the command, while in rf_protocols
# it's the number of additional times to send it, so we need to add 1 here.
repeat_count=command.repeat_count + 1,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=RadioFrequencyInfo,
entity_type=EsphomeRadioFrequencyEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & RadioFrequencyCapability.TRANSMITTER
),
)
@@ -0,0 +1,113 @@
"""Home Assistant-aware ESPHome serial proxy URI handler for serialx."""
from __future__ import annotations
import asyncio
from typing import cast
from aioesphomeapi import APIClient
from serialx import register_uri_handler
from serialx.platforms.serial_esphome import (
ESPHomeSerial,
ESPHomeSerialTransport,
InvalidSettingsError,
)
from yarl import URL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, async_get_hass
from .const import DOMAIN
from .entry_data import ESPHomeConfigEntry
SCHEME = "esphome-hass://"
# This is required so that serialx can safely query Core for an instance of an
# aioesphomeapi client. We cannot make any assumptions here, some packages run separate
# asyncio event loops in dedicated threads.
_HASS_LOOP: asyncio.AbstractEventLoop | None = None
def set_hass_loop(loop: asyncio.AbstractEventLoop) -> None:
"""Store a reference to the Core event loop."""
global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement
_HASS_LOOP = loop
def build_url(entry_id: str, port_name: str) -> URL:
"""Build a canonical `esphome-hass://` URL."""
return URL.build(
scheme="esphome-hass",
host="esphome",
path=f"/{entry_id}",
query={"port_name": port_name},
)
async def _resolve_client(entry_id: str) -> APIClient:
"""Look up the `APIClient` for a specific config entry."""
# This function is async specifically so that we can get a reference to the Home
# Assistant Core instance from its own thread
hass: HomeAssistant = async_get_hass()
entry = cast(ESPHomeConfigEntry, hass.config_entries.async_get_entry(entry_id))
if entry is None or entry.domain != DOMAIN:
raise InvalidSettingsError(f"No ESPHome config entry with id {entry_id!r}")
if entry.state is not ConfigEntryState.LOADED:
raise InvalidSettingsError(f"ESPHome config entry {entry_id!r} is not loaded")
return entry.runtime_data.client
class HassESPHomeSerial(ESPHomeSerial):
"""ESPHomeSerial that resolves an HA config entry's APIClient from the URL."""
_api: APIClient | None
_path: str | None
async def _async_open(self) -> None:
"""Resolve the HA config entry's APIClient, then open the proxy."""
if self._api is None and self._path is not None:
parsed = URL(str(self._path))
entry_id = parsed.path.lstrip("/")
if not entry_id:
raise InvalidSettingsError(
f"No ESPHome config entry id in URL {self._path!r}"
)
if "port_name" not in parsed.query:
raise InvalidSettingsError("Port name is required")
self._port_name = parsed.query["port_name"]
hass_loop = _HASS_LOOP
if hass_loop is None:
raise InvalidSettingsError(
"ESPHome integration has not registered its event loop"
)
# Fetch the `APIClient` from the Core via the appropriate event loop
self._api = await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(_resolve_client(entry_id), hass_loop)
)
self._client_loop = self._api._loop # noqa: SLF001
await super()._async_open()
class HassESPHomeSerialTransport(ESPHomeSerialTransport):
"""Transport variant that constructs :class:`HassESPHomeSerial`."""
transport_name = "esphome-hass"
_serial_cls = HassESPHomeSerial
register_uri_handler(
scheme=SCHEME,
unique_scheme=SCHEME,
sync_cls=HassESPHomeSerial,
async_transport_cls=HassESPHomeSerialTransport,
)
+7 -4
View File
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfVolume
from homeassistant.const import UnitOfVolume, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -34,7 +34,8 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = (
key="current_interval",
translation_key="current_interval",
suggested_display_precision=2,
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/m",
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
@@ -65,14 +66,16 @@ FLUME_QUERIES_SENSOR: tuple[SensorEntityDescription, ...] = (
key="last_60_min",
translation_key="last_60_min",
suggested_display_precision=2,
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/h",
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_HOUR,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="last_24_hrs",
translation_key="last_24_hrs",
suggested_display_precision=2,
native_unit_of_measurement=f"{UnitOfVolume.GALLONS}/d",
native_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_DAY,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
@@ -87,8 +87,7 @@ def async_wifi_bulb_for_host(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the flux_led component."""
domain_data = hass.data.setdefault(DOMAIN, {})
domain_data[FLUX_LED_DISCOVERY] = []
hass.data[FLUX_LED_DISCOVERY] = []
@callback
def _async_start_background_discovery(*_: Any) -> None:
+3 -1
View File
@@ -9,8 +9,10 @@ from flux_led.const import (
COLOR_MODE_RGBW as FLUX_COLOR_MODE_RGBW,
COLOR_MODE_RGBWW as FLUX_COLOR_MODE_RGBWW,
)
from flux_led.scanner import FluxLEDDiscovery
from homeassistant.components.light import ColorMode
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "flux_led"
@@ -34,7 +36,7 @@ DEFAULT_NETWORK_SCAN_INTERVAL: Final = 120
DEFAULT_SCAN_INTERVAL: Final = 5
DEFAULT_EFFECT_SPEED: Final = 50
FLUX_LED_DISCOVERY: Final = "flux_led_discovery"
FLUX_LED_DISCOVERY: HassKey[list[FluxLEDDiscovery]] = HassKey(DOMAIN)
FLUX_LED_EXCEPTIONS: Final = (
TimeoutError,
@@ -1,5 +1,4 @@
"""The Flux LED/MagicLight integration discovery."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations
@@ -154,8 +153,7 @@ def async_update_entry_from_discovery(
@callback
def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | None:
"""Check if a device was already discovered via a broadcast discovery."""
discoveries: list[FluxLEDDiscovery] = hass.data[DOMAIN][FLUX_LED_DISCOVERY]
for discovery in discoveries:
for discovery in hass.data[FLUX_LED_DISCOVERY]:
if discovery[ATTR_IPADDR] == host:
return discovery
return None
@@ -164,10 +162,10 @@ def async_get_discovery(hass: HomeAssistant, host: str) -> FluxLEDDiscovery | No
@callback
def async_clear_discovery_cache(hass: HomeAssistant, host: str) -> None:
"""Clear the host from the discovery cache."""
domain_data = hass.data[DOMAIN]
discoveries: list[FluxLEDDiscovery] = domain_data[FLUX_LED_DISCOVERY]
domain_data[FLUX_LED_DISCOVERY] = [
discovery for discovery in discoveries if discovery[ATTR_IPADDR] != host
hass.data[FLUX_LED_DISCOVERY] = [
discovery
for discovery in hass.data[FLUX_LED_DISCOVERY]
if discovery[ATTR_IPADDR] != host
]
@@ -44,6 +44,8 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
self._data = user_input
# Check if already configured
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(self._data[CONF_HOST])
self._abort_if_unique_id_configured()
-2
View File
@@ -66,8 +66,6 @@ SWITCH_TYPE_WIFINETWORK = "WiFiNetwork"
BUTTON_TYPE_WOL = "WakeOnLan"
UPTIME_DEVIATION = 5
FRITZ_EXCEPTIONS = (
ConnectionError,
FritzActionError,
+6 -19
View File
@@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from .const import DSL_CONNECTION, UPTIME_DEVIATION
from .const import DSL_CONNECTION
from .coordinator import FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
from .models import ConnectionInfo
@@ -39,31 +39,18 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime:
"""Calculate uptime with deviation."""
delta_uptime = utcnow() - timedelta(seconds=seconds_uptime)
if (
not last_value
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
):
return delta_uptime
return last_value
def _retrieve_device_uptime_state(
status: FritzStatus, last_value: datetime
status: FritzStatus, last_value: datetime | None
) -> datetime:
"""Return uptime from device."""
return _uptime_calculation(status.device_uptime, last_value)
return utcnow() - timedelta(seconds=status.device_uptime)
def _retrieve_connection_uptime_state(
status: FritzStatus, last_value: datetime | None
) -> datetime:
"""Return uptime from connection."""
return _uptime_calculation(status.connection_uptime, last_value)
return utcnow() - timedelta(seconds=status.connection_uptime)
def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str:
@@ -200,7 +187,7 @@ CONNECTION_SENSOR_TYPES: tuple[FritzConnectionSensorEntityDescription, ...] = (
FritzConnectionSensorEntityDescription(
key="connection_uptime",
translation_key="connection_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_connection_uptime_state,
),
@@ -308,7 +295,7 @@ DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = (
FritzDeviceSensorEntityDescription(
key="device_uptime",
translation_key="device_uptime",
device_class=SensorDeviceClass.TIMESTAMP,
device_class=SensorDeviceClass.UPTIME,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_device_uptime_state,
),
@@ -407,6 +407,12 @@ def async_remove_panel(
hass.bus.async_fire(EVENT_PANELS_UPDATED)
@callback
def async_panel_exists(hass: HomeAssistant, frontend_url_path: str) -> bool:
"""Return if a panel is registered for the given frontend URL path."""
return frontend_url_path in hass.data.get(DATA_PANELS, {})
def add_extra_js_url(hass: HomeAssistant, url: str, es5: bool = False) -> None:
"""Register extra js or module url to load.
+63 -49
View File
@@ -9,6 +9,7 @@ from fumis import (
Fumis,
FumisAuthenticationError,
FumisConnectionError,
FumisInfo,
FumisStoveOfflineError,
)
import voluptuous as vol
@@ -51,23 +52,10 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
fumis = Fumis(
mac=self._discovered_mac,
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
errors, info = await self._validate_input(
self._discovered_mac, user_input[CONF_PIN]
)
try:
info = await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if info:
return self.async_create_entry(
title=info.controller.model_name or "Fumis",
data={
@@ -96,23 +84,8 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
mac = user_input[CONF_MAC].replace(":", "").replace("-", "").upper()
fumis = Fumis(
mac=mac,
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
)
try:
info = await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
errors, info = await self._validate_input(mac, user_input[CONF_PIN])
if info:
await self.async_set_unique_id(format_mac(mac), raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
@@ -141,6 +114,35 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of a Fumis stove."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
errors, _ = await self._validate_input(
reconfigure_entry.data[CONF_MAC], user_input[CONF_PIN]
)
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_PIN: user_input[CONF_PIN]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -155,23 +157,10 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
reauth_entry = self._get_reauth_entry()
fumis = Fumis(
mac=reauth_entry.data[CONF_MAC],
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
errors, _ = await self._validate_input(
reauth_entry.data[CONF_MAC], user_input[CONF_PIN]
)
try:
await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if not errors:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PIN: user_input[CONF_PIN]},
@@ -188,3 +177,28 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def _validate_input(
self, mac: str, pin: str
) -> tuple[dict[str, str], FumisInfo | None]:
"""Validate credentials, returning errors and info."""
errors: dict[str, str] = {}
fumis = Fumis(
mac=mac,
password=pin,
session=async_get_clientsession(self.hass),
)
try:
info = await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return errors, info
return errors, None
+1 -1
View File
@@ -13,5 +13,5 @@
"iot_class": "cloud_polling",
"loggers": ["fumis"],
"quality_scale": "bronze",
"requirements": ["fumis==0.2.1"]
"requirements": ["fumis==0.3.0"]
}
@@ -62,7 +62,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration does not raise any repairable issues.
+1 -4
View File
@@ -202,10 +202,7 @@ SENSORS: tuple[FumisSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.DIAGNOSTIC,
has_fn=lambda data: (
data.controller.time_to_service is not None
and data.controller.time_to_service >= 0
),
has_fn=lambda data: data.controller.time_to_service is not None,
value_fn=lambda data: data.controller.time_to_service,
),
FumisSensorEntityDescription(
+11 -1
View File
@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -29,6 +30,15 @@
},
"description": "The PIN code for your stove has changed. Please enter the new PIN code to re-authenticate."
},
"reconfigure": {
"data": {
"pin": "[%key:component::fumis::config::step::user::data::pin%]"
},
"data_description": {
"pin": "[%key:component::fumis::config::step::user::data_description::pin%]"
},
"description": "Reconfigure your Fumis pellet stove connection."
},
"user": {
"data": {
"mac": "MAC address",
+32 -35
View File
@@ -2,17 +2,19 @@
from __future__ import annotations
from types import MappingProxyType
from aiogithubapi import GitHubAPI
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
from .const import CONF_REPOSITORIES, DOMAIN, LOGGER
from .const import CONF_REPOSITORIES, CONF_REPOSITORY, SUBENTRY_TYPE_REPOSITORY
from .coordinator import GithubConfigEntry, GitHubDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -26,10 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
client_name=SERVER_SOFTWARE,
)
repositories: list[str] = entry.options[CONF_REPOSITORIES]
entry.runtime_data = {}
for repository in repositories:
for repository_subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_REPOSITORY):
repository = repository_subentry.data[CONF_REPOSITORY]
coordinator = GitHubDataUpdateCoordinator(
hass=hass,
config_entry=entry,
@@ -42,41 +43,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo
if not entry.pref_disable_polling:
await coordinator.subscribe()
entry.runtime_data[repository] = coordinator
entry.runtime_data[repository_subentry.subentry_id] = coordinator
async_cleanup_device_registry(hass=hass, entry=entry)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@callback
def async_cleanup_device_registry(
hass: HomeAssistant,
entry: GithubConfigEntry,
) -> None:
"""Remove entries form device registry if we no longer track the repository."""
device_registry = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(
registry=device_registry,
config_entry_id=entry.entry_id,
)
for device in devices:
for item in device.identifiers:
if item[0] == DOMAIN and item[1] not in entry.options[CONF_REPOSITORIES]:
LOGGER.debug(
(
"Unlinking device %s for untracked repository %s from config"
" entry %s"
),
device.id,
item[1],
entry.entry_id,
)
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
break
async def async_update_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None:
"""Update entry."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
@@ -86,3 +63,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b
coordinator.unsubscribe()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bool:
"""Migrate old entry."""
if entry.minor_version == 1:
# In minor version 2 we migrated repositories from entry options to
# subentries, so we need to convert the list from
# entry.options[CONF_REPOSITORIES] into individual subentries.
for repository in entry.options[CONF_REPOSITORIES]:
subentry = ConfigSubentry(
data=MappingProxyType({CONF_REPOSITORY: repository}),
subentry_type=SUBENTRY_TYPE_REPOSITORY,
title=repository,
unique_id=repository,
)
hass.config_entries.async_add_subentry(entry, subentry)
hass.config_entries.async_update_entry(entry, minor_version=2)
return True
+52 -61
View File
@@ -19,23 +19,31 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
SERVER_SOFTWARE,
async_get_clientsession,
)
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER
from .const import (
CLIENT_ID,
CONF_REPOSITORY,
DEFAULT_REPOSITORIES,
DOMAIN,
LOGGER,
SUBENTRY_TYPE_REPOSITORY,
)
async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]:
"""Return a list of repositories that the user owns or has starred."""
client = GitHubAPI(token=access_token, session=async_get_clientsession(hass))
repositories = set()
repositories: set[str] = set()
async def _get_starred_repositories() -> None:
response = await client.user.starred(params={"per_page": 100})
@@ -53,7 +61,7 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]:
for result in results:
response.data.extend(result.data)
repositories.update(response.data)
repositories.update(repo.full_name for repo in response.data)
async def _get_personal_repositories() -> None:
response = await client.user.repos(params={"per_page": 100})
@@ -71,7 +79,7 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]:
for result in results:
response.data.extend(result.data)
repositories.update(response.data)
repositories.update(repo.full_name for repo in response.data)
try:
await asyncio.gather(
@@ -82,21 +90,26 @@ async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]:
)
except GitHubException:
return DEFAULT_REPOSITORIES
repositories.update(DEFAULT_REPOSITORIES)
if len(repositories) == 0:
return DEFAULT_REPOSITORIES
repositories.update(DEFAULT_REPOSITORIES)
return sorted(
(repo.full_name for repo in repositories),
key=str.casefold,
)
current_repositories = {
subentry.data[CONF_REPOSITORY]
for entry in hass.config_entries.async_entries(DOMAIN)
for subentry in entry.subentries.values()
if subentry.subentry_type == SUBENTRY_TYPE_REPOSITORY
}
repositories = repositories - current_repositories
return sorted(repositories, key=str.casefold)
class GitHubConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for GitHub."""
VERSION = 1
MINOR_VERSION = 2
login_task: asyncio.Task | None = None
@@ -106,6 +119,14 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN):
self._login: GitHubLoginOauthModel | None = None
self._login_device: GitHubLoginDeviceModel | None = None
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {SUBENTRY_TYPE_REPOSITORY: RepositoryFlowHandler}
async def async_step_user(
self,
user_input: dict[str, Any] | None = None,
@@ -153,7 +174,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN):
if self.login_task.done():
if self.login_task.exception():
return self.async_show_progress_done(next_step_id="could_not_register")
return self.async_show_progress_done(next_step_id="repositories")
return self.async_show_progress_done(next_step_id="done")
if TYPE_CHECKING:
# mypy is not aware that we can't get here without having this set already
@@ -169,33 +190,18 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN):
progress_task=self.login_task,
)
async def async_step_repositories(
async def async_step_done(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle repositories step."""
"""Create the config entry after successful device authentication."""
if TYPE_CHECKING:
# mypy is not aware that we can't get here without having this set already
assert self._login is not None
if not user_input:
repositories = await get_repositories(self.hass, self._login.access_token)
return self.async_show_form(
step_id="repositories",
data_schema=vol.Schema(
{
vol.Required(CONF_REPOSITORIES): cv.multi_select(
{k: k for k in repositories}
),
}
),
)
return self.async_create_entry(
title="",
data={CONF_ACCESS_TOKEN: self._login.access_token},
options={CONF_REPOSITORIES: user_input[CONF_REPOSITORIES]},
)
async def async_step_could_not_register(
@@ -205,46 +211,31 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle issues that need transition await from progress step."""
return self.async_abort(reason="could_not_register")
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
class RepositoryFlowHandler(ConfigSubentryFlow):
"""Handle repository subentry flow."""
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle a option flow for GitHub."""
async def async_step_init(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Handle options flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle repository subentry flow."""
if not user_input:
configured_repositories: list[str] = self.config_entry.options[
CONF_REPOSITORIES
]
repositories = await get_repositories(
self.hass, self.config_entry.data[CONF_ACCESS_TOKEN]
self.hass, self._get_entry().data[CONF_ACCESS_TOKEN]
)
# In case the user has removed a starred repository that is already tracked
for repository in configured_repositories:
if repository not in repositories:
repositories.append(repository)
return self.async_show_form(
step_id="init",
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_REPOSITORIES,
default=configured_repositories,
): cv.multi_select({k: k for k in repositories}),
vol.Required(CONF_REPOSITORY): SelectSelector(
SelectSelectorConfig(sort=True, options=repositories)
),
}
),
)
repository = user_input[CONF_REPOSITORY]
return self.async_create_entry(title="", data=user_input)
return self.async_create_entry(
title=repository, data=user_input, unique_id=repository
)
+3
View File
@@ -15,6 +15,9 @@ DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"]
FALLBACK_UPDATE_INTERVAL = timedelta(hours=1, minutes=30)
CONF_REPOSITORIES = "repositories"
CONF_REPOSITORY = "repository"
SUBENTRY_TYPE_REPOSITORY = "repository"
REFRESH_EVENT_TYPES = (
@@ -21,7 +21,7 @@ async def async_get_config_entry_diagnostics(
config_entry: GithubConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data = {"options": {**config_entry.options}}
data: dict[str, Any] = {}
client = GitHubAPI(
token=config_entry.data[CONF_ACCESS_TOKEN],
session=async_get_clientsession(hass),
@@ -38,7 +38,7 @@ async def async_get_config_entry_diagnostics(
repositories = config_entry.runtime_data
data["repositories"] = {}
for repository, coordinator in repositories.items():
data["repositories"][repository] = coordinator.data
for coordinator in repositories.values():
data["repositories"][coordinator.data["full_name"]] = coordinator.data
return data
+8 -7
View File
@@ -150,13 +150,14 @@ async def async_setup_entry(
) -> None:
"""Set up GitHub sensor based on a config entry."""
repositories = entry.runtime_data
async_add_entities(
(
GitHubSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
for coordinator in repositories.values()
),
)
for subentry_id, coordinator in repositories.items():
async_add_entities(
(
GitHubSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry_id,
)
class GitHubSensorEntity(CoordinatorEntity[GitHubDataUpdateCoordinator], SensorEntity):
+20 -6
View File
@@ -7,12 +7,26 @@
"progress": {
"wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```"
},
"step": {
"repositories": {
"data": {
"repositories": "Select repositories to track."
},
"title": "Configure repositories"
"step": {}
},
"config_subentries": {
"repository": {
"abort": {
"already_configured": "Repository is already configured"
},
"entry_type": "[%key:component::github::config_subentries::repository::step::user::data::repository%]",
"initiate_flow": {
"user": "Add repository"
},
"step": {
"user": {
"data": {
"repository": "Repository"
},
"data_description": {
"repository": "The repository to track"
}
}
}
}
},
+45 -4
View File
@@ -87,7 +87,18 @@ async def async_setup_entry(
class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Update entity to handle updates for the Supervisor add-ons."""
"""Update entity to handle updates for the Supervisor add-ons.
The ``addon_manager_update`` job emits a ``done=True`` WS event as soon as
Supervisor finishes the container work, a few milliseconds before the
``/store/addons/<slug>/update`` HTTP call returns. If we clear
``_attr_in_progress`` on that event while the coordinator data still
carries the pre-update version, the UI briefly flips back to
"Update available" before ``async_install`` can refresh. ``_update_ongoing``
survives both the WS done event and the base ``UpdateEntity`` reset, so
the installing state remains until the coordinator confirms a new
``installed_version``.
"""
_attr_supported_features = (
UpdateEntityFeature.INSTALL
@@ -95,6 +106,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
| UpdateEntityFeature.RELEASE_NOTES
| UpdateEntityFeature.PROGRESS
)
_update_ongoing: bool = False
_version_before_update: str | None = None
@property
def _addon_data(self) -> dict:
@@ -121,6 +134,13 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
"""Version installed and in use."""
return self._addon_data[ATTR_VERSION]
@property
def in_progress(self) -> bool | None:
"""Return combined progress from the update job and refresh phase."""
if self._update_ongoing:
return True
return self._attr_in_progress
@property
def entity_picture(self) -> str | None:
"""Return the icon of the add-on if any."""
@@ -154,13 +174,34 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
**kwargs: Any,
) -> None:
"""Install an update."""
self._version_before_update = self.installed_version
self._update_ongoing = True
self._attr_in_progress = True
self.async_write_ha_state()
await update_addon(
self.hass, self._addon_slug, backup, self.title, self.installed_version
)
try:
await update_addon(
self.hass, self._addon_slug, backup, self.title, self.installed_version
)
except HomeAssistantError:
self._update_ongoing = False
self._version_before_update = None
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
raise
await self.coordinator.async_refresh()
@callback
def _handle_coordinator_update(self) -> None:
"""Clear the ongoing flag once the installed version has changed."""
if (
self._update_ongoing
and self.installed_version != self._version_before_update
):
self._update_ongoing = False
self._version_before_update = None
super()._handle_coordinator_update()
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
@@ -219,6 +219,8 @@ class HiveOptionsFlowHandler(OptionsFlow):
schema = vol.Schema(
{
# Polling interval is user-configurable, which is no longer allowed
# pylint: disable-next=hass-config-flow-polling-field
vol.Optional(CONF_SCAN_INTERVAL, default=self.interval): vol.All(
vol.Coerce(int), vol.Range(min=30)
)
@@ -225,7 +225,7 @@ async def async_attach_trigger( # noqa: C901
elif (
new_state.domain == "sensor"
and new_state.attributes.get(ATTR_DEVICE_CLASS)
== sensor.SensorDeviceClass.TIMESTAMP
in (sensor.SensorDeviceClass.TIMESTAMP, sensor.SensorDeviceClass.UPTIME)
and new_state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
):
trigger_dt = dt_util.parse_datetime(new_state.state)
+65 -8
View File
@@ -1,11 +1,11 @@
"""The Homee lock platform."""
from typing import Any
from typing import TYPE_CHECKING, Any
from pyHomee.const import AttributeChangedBy, AttributeType
from pyHomee.model import HomeeNode
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.lock import LockEntity
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -15,6 +15,24 @@ from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
LOCK_STATE_UNLOCKED = 0.0
LOCK_STATE_LOCKED = 1.0
def _determine_lock_state_open(attribute: HomeeAttribute) -> float | None:
"""Return the attribute value that momentarily unlatches the lock.
Different homee-compatible locks encode the "open" (unlatch) command
differently. The Hörmann SmartKey uses a signed range {-1, 0, 1}
where -1 is unlatch; other devices extend above with {0, 1, 2}.
Returns None when the device only supports two states.
"""
if attribute.maximum == 2.0:
return 2.0
if attribute.minimum == -1.0:
return -1.0
return None
async def add_lock_entities(
config_entry: HomeeConfigEntry,
@@ -45,20 +63,53 @@ class HomeeLock(HomeeEntity, LockEntity):
_attr_name = None
def __init__(self, attribute: HomeeAttribute, entry: HomeeConfigEntry) -> None:
"""Initialize the homee lock."""
super().__init__(attribute, entry)
self._lock_state_open = _determine_lock_state_open(attribute)
if self._lock_state_open is not None:
self._attr_supported_features = LockEntityFeature.OPEN
@property
def is_locked(self) -> bool:
"""Return if lock is locked."""
return self._attribute.current_value == 1.0
return self._attribute.current_value == LOCK_STATE_LOCKED
@property
def is_open(self) -> bool:
"""Return if lock is open (unlatched)."""
# Require target_value too, so mid-transition away from "open" resolves
# to is_locking/is_unlocking rather than OPEN (HA state precedence).
return (
self._lock_state_open is not None
and self._attribute.current_value == self._lock_state_open
and self._attribute.target_value == self._lock_state_open
)
@property
def is_locking(self) -> bool:
"""Return if lock is locking."""
return self._attribute.target_value > self._attribute.current_value
return (
self._attribute.target_value == LOCK_STATE_LOCKED
and self._attribute.current_value != LOCK_STATE_LOCKED
)
@property
def is_unlocking(self) -> bool:
"""Return if lock is unlocking."""
return self._attribute.target_value < self._attribute.current_value
return (
self._attribute.target_value == LOCK_STATE_UNLOCKED
and self._attribute.current_value != LOCK_STATE_UNLOCKED
)
@property
def is_opening(self) -> bool:
"""Return if lock is opening (unlatching)."""
return (
self._lock_state_open is not None
and self._attribute.target_value == self._lock_state_open
and self._attribute.current_value != self._lock_state_open
)
@property
def changed_by(self) -> str:
@@ -80,8 +131,14 @@ class HomeeLock(HomeeEntity, LockEntity):
async def async_lock(self, **kwargs: Any) -> None:
"""Lock specified lock. A code to lock the lock with may be specified."""
await self.async_set_homee_value(1)
await self.async_set_homee_value(LOCK_STATE_LOCKED)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock specified lock. A code to unlock the lock with may be specified."""
await self.async_set_homee_value(0)
await self.async_set_homee_value(LOCK_STATE_UNLOCKED)
async def async_open(self, **kwargs: Any) -> None:
"""Open (unlatch) the lock."""
if TYPE_CHECKING:
assert self._lock_state_open is not None
await self.async_set_homee_value(self._lock_state_open)
@@ -0,0 +1,20 @@
"""The Honeywell String Lights integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Honeywell String Lights from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,61 @@
"""Config flow for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Any
from rf_protocols import RadioFrequencyCommand
import voluptuous as vol
from homeassistant.components.radio_frequency import async_get_transmitters
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from .const import CONF_TRANSMITTER, DOMAIN
from .light import COMMANDS
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Honeywell String Lights."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
COMMANDS.load_command, "turn_on"
)
try:
transmitters = async_get_transmitters(
self.hass, sample_command.frequency, sample_command.modulation
)
except HomeAssistantError:
return self.async_abort(reason="no_transmitters")
if not transmitters:
return self.async_abort(reason="no_compatible_transmitters")
if user_input is not None:
registry = er.async_get(self.hass)
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
assert entity_entry is not None
await self.async_set_unique_id(entity_entry.id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Honeywell String Lights",
data={CONF_TRANSMITTER: entity_entry.id},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_TRANSMITTER): selector.EntitySelector(
selector.EntitySelectorConfig(include_entities=transmitters),
),
}
),
)
@@ -0,0 +1,9 @@
"""Constants for the Honeywell String Lights integration."""
from __future__ import annotations
from typing import Final
DOMAIN: Final = "honeywell_string_lights"
CONF_TRANSMITTER: Final = "transmitter"
@@ -0,0 +1,76 @@
"""Common entity for Honeywell String Lights integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_TRANSMITTER, DOMAIN
_LOGGER = logging.getLogger(__name__)
class HoneywellStringLightsEntity(Entity):
"""Honeywell String Lights base entity."""
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the entity."""
self._transmitter = entry.data[CONF_TRANSMITTER]
self._attr_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Honeywell",
model="String Lights",
)
async def async_added_to_hass(self) -> None:
"""Subscribe to transmitter entity state changes."""
await super().async_added_to_hass()
transmitter_entity_id = er.async_validate_entity_id(
er.async_get(self.hass), self._transmitter
)
@callback
def _async_transmitter_state_changed(
event: Event[EventStateChangedData],
) -> None:
"""Handle transmitter entity state changes."""
new_state = event.data["new_state"]
transmitter_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
if transmitter_available != self.available:
_LOGGER.info(
"Transmitter %s used by %s is %s",
transmitter_entity_id,
self.entity_id,
"available" if transmitter_available else "unavailable",
)
self._attr_available = transmitter_available
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass,
[transmitter_entity_id],
_async_transmitter_state_changed,
)
)
# Set initial availability based on current transmitter entity state
transmitter_state = self.hass.states.get(transmitter_entity_id)
self._attr_available = (
transmitter_state is not None
and transmitter_state.state != STATE_UNAVAILABLE
)
@@ -0,0 +1,65 @@
"""Light platform for Honeywell String Lights."""
from __future__ import annotations
from typing import Any
from rf_protocols import get_codes
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .entity import HoneywellStringLightsEntity
PARALLEL_UPDATES = 1
COMMANDS = get_codes("honeywell/string_lights")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell String Lights light platform."""
async_add_entities([HoneywellStringLight(config_entry)])
class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEntity):
"""Representation of a Honeywell String Lights set controlled via RF."""
_attr_assumed_state = True
_attr_color_mode = ColorMode.ONOFF
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_name = None
_attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""Restore last known state."""
await super().async_added_to_hass()
if (last_state := await self.async_get_last_state()) is not None:
self._attr_is_on = last_state.state == STATE_ON
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
await self._async_send_command("turn_on")
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self._async_send_command("turn_off")
self._attr_is_on = False
self.async_write_ha_state()
async def _async_send_command(self, name: str) -> None:
"""Load the named command and send it via the configured transmitter."""
command = await COMMANDS.async_load_command(name)
await async_send_command(
self.hass, self._transmitter, command, context=self._context
)
@@ -0,0 +1,12 @@
{
"domain": "honeywell_string_lights",
"name": "Honeywell String Lights",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["radio_frequency"],
"documentation": "https://www.home-assistant.io/integrations/honeywell_string_lights",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze",
"requirements": ["rf-protocols==2.1.0"]
}
@@ -0,0 +1,124 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not register custom service actions.
appropriate-polling:
status: exempt
comment: |
This integration transmits RF commands and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not register custom service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not use runtime data.
test-before-configure:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
test-before-setup:
status: exempt
comment: |
RF transmission is a one-way broadcast with no device to contact.
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no options.
docs-installation-parameters: todo
entity-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
integration-owner: done
log-when-unavailable:
status: exempt
comment: |
RF transmission is a one-way broadcast; the light uses assumed state.
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not authenticate.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
RF devices cannot be discovered.
docs-data-update:
status: exempt
comment: |
RF transmission is one-way; there is no data update.
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry represents a single static device.
entity-category:
status: exempt
comment: |
The single entity represents the primary device function.
entity-device-class:
status: exempt
comment: |
Light entities do not have device classes.
entity-disabled-by-default:
status: exempt
comment: |
The single entity represents the primary device function.
entity-translations:
status: exempt
comment: |
The entity uses the device name.
exception-translations: todo
icon-translations:
status: exempt
comment: |
Light uses the default icon for its state.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No known repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry represents a single static device.
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
This integration does not use a web session.
strict-typing: todo
@@ -0,0 +1,19 @@
{
"config": {
"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."
},
"step": {
"user": {
"data": {
"transmitter": "Radio frequency transmitter"
},
"data_description": {
"transmitter": "The radio frequency transmitter used to control the Honeywell String Lights."
}
}
}
}
}
@@ -0,0 +1,293 @@
"""Binary sensor platform for Qube Heat Pump."""
from __future__ import annotations
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,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from .entity import QubeEntity
PARALLEL_UPDATES = 0
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import QubeConfigEntry
from .coordinator import QubeCoordinator
@dataclass(frozen=True, kw_only=True)
class QubeBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Binary sensor entity description for Qube Heat Pump."""
value_fn: Callable[[QubeState], bool | None]
BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
# Outputs
QubeBinarySensorEntityDescription(
key="source_pump",
translation_key="source_pump",
value_fn=lambda data: data.dout_srcpmp_val,
),
QubeBinarySensorEntityDescription(
key="user_pump",
translation_key="user_pump",
value_fn=lambda data: data.dout_usrpmp_val,
),
QubeBinarySensorEntityDescription(
key="four_way_valve",
translation_key="four_way_valve",
value_fn=lambda data: data.dout_fourwayvlv_val,
),
QubeBinarySensorEntityDescription(
key="cooling_output",
translation_key="cooling_output",
value_fn=lambda data: data.dout_cooling_val,
),
QubeBinarySensorEntityDescription(
key="three_way_valve",
translation_key="three_way_valve",
value_fn=lambda data: data.dout_threewayvlv_val,
),
QubeBinarySensorEntityDescription(
key="buffer_pump",
translation_key="buffer_pump",
value_fn=lambda data: data.dout_bufferpmp_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_1",
translation_key="heater_step_1",
value_fn=lambda data: data.dout_heaterstep1_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_2",
translation_key="heater_step_2",
value_fn=lambda data: data.dout_heaterstep2_val,
),
QubeBinarySensorEntityDescription(
key="heater_step_3",
translation_key="heater_step_3",
value_fn=lambda data: data.dout_heaterstep3_val,
),
# System status
QubeBinarySensorEntityDescription(
key="keypad",
translation_key="keypad",
value_fn=lambda data: data.keybonoff,
),
QubeBinarySensorEntityDescription(
key="day_mode",
translation_key="day_mode",
value_fn=lambda data: data.daynightmode,
),
# Alarms
QubeBinarySensorEntityDescription(
key="alarm_antilegionella_timeout",
translation_key="alarm_antilegionella_timeout",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.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,
),
QubeBinarySensorEntityDescription(
key="alarm_dewpoint",
translation_key="alarm_dewpoint",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.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,
),
QubeBinarySensorEntityDescription(
key="alarm_flow",
translation_key="alarm_flow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.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,
),
QubeBinarySensorEntityDescription(
key="alarm_cooling",
translation_key="alarm_cooling",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.coolingalrms,
),
QubeBinarySensorEntityDescription(
key="alarm_heating",
translation_key="alarm_heating",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.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,
),
QubeBinarySensorEntityDescription(
key="alarm_source",
translation_key="alarm_source",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.srsalrm,
),
QubeBinarySensorEntityDescription(
key="alarm_global",
translation_key="alarm_global",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.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,
),
# Sensor/controller status
QubeBinarySensorEntityDescription(
key="room_sensor_enabled",
translation_key="room_sensor_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.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,
),
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,
),
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,
),
# Demand signals
QubeBinarySensorEntityDescription(
key="plant_demand",
translation_key="plant_demand",
value_fn=lambda data: data.plantdemand,
),
QubeBinarySensorEntityDescription(
key="external_demand",
translation_key="external_demand",
value_fn=lambda data: data.id_demand,
),
QubeBinarySensorEntityDescription(
key="thermostat_demand",
translation_key="thermostat_demand",
value_fn=lambda data: data.thermostatdemand,
),
# Digital inputs
QubeBinarySensorEntityDescription(
key="summer_mode",
translation_key="summer_mode",
value_fn=lambda data: data.id_summerwinter,
),
QubeBinarySensorEntityDescription(
key="dewpoint",
translation_key="dewpoint",
value_fn=lambda data: data.dewpoint,
),
QubeBinarySensorEntityDescription(
key="booster_security",
translation_key="booster_security",
value_fn=lambda data: data.boostersecurity,
),
QubeBinarySensorEntityDescription(
key="source_flow",
translation_key="source_flow",
value_fn=lambda data: data.srcflw,
),
QubeBinarySensorEntityDescription(
key="anti_legionella",
translation_key="anti_legionella",
value_fn=lambda data: data.req_antileg_1,
),
# Energy
QubeBinarySensorEntityDescription(
key="pv_surplus",
translation_key="pv_surplus",
value_fn=lambda data: data.surplus_pv,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: QubeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube binary sensors."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
QubeBinarySensor(coordinator, entry, description)
for description in BINARY_SENSOR_TYPES
)
class QubeBinarySensor(QubeEntity, BinarySensorEntity):
"""Qube binary sensor entity."""
entity_description: QubeBinarySensorEntityDescription
def __init__(
self,
coordinator: QubeCoordinator,
entry: QubeConfigEntry,
description: QubeBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator, entry)
self.entity_description = description
self._attr_unique_id = f"{entry.entry_id}-{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.data)
@@ -3,7 +3,7 @@
from homeassistant.const import Platform
DOMAIN = "hr_energy_qube"
PLATFORMS = (Platform.SENSOR,)
PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR)
DEFAULT_PORT = 502
DEFAULT_SCAN_INTERVAL = 15
@@ -20,6 +20,116 @@
}
},
"entity": {
"binary_sensor": {
"alarm_antilegionella_timeout": {
"name": "Anti-legionella timeout alarm"
},
"alarm_central_heating": {
"name": "Central heating alarm"
},
"alarm_compressor": {
"name": "Compressor alarm"
},
"alarm_cooling": {
"name": "Cooling alarm"
},
"alarm_dewpoint": {
"name": "Dewpoint alarm"
},
"alarm_dhw_timeout": {
"name": "DHW timeout alarm"
},
"alarm_flow": {
"name": "Flow alarm"
},
"alarm_global": {
"name": "Global alarm"
},
"alarm_heating": {
"name": "Heating alarm"
},
"alarm_source": {
"name": "Source alarm"
},
"alarm_supply_too_hot": {
"name": "Supply too hot alarm"
},
"alarm_working_hours": {
"name": "Working hours alarm"
},
"anti_legionella": {
"name": "Anti-legionella"
},
"booster_security": {
"name": "Booster security"
},
"buffer_pump": {
"name": "Buffer pump"
},
"buffer_sensor_enabled": {
"name": "Buffer sensor enabled"
},
"cooling_output": {
"name": "Cooling output"
},
"day_mode": {
"name": "Day mode"
},
"dewpoint": {
"name": "Dewpoint"
},
"dhw_controller_enabled": {
"name": "DHW controller enabled"
},
"external_demand": {
"name": "External demand"
},
"four_way_valve": {
"name": "Four-way valve"
},
"heater_step_1": {
"name": "Heater step 1"
},
"heater_step_2": {
"name": "Heater step 2"
},
"heater_step_3": {
"name": "Heater step 3"
},
"keypad": {
"name": "Keypad"
},
"plant_demand": {
"name": "Plant demand"
},
"plant_sensor_enabled": {
"name": "Plant sensor enabled"
},
"pv_surplus": {
"name": "PV surplus"
},
"room_sensor_enabled": {
"name": "Room sensor enabled"
},
"source_flow": {
"name": "Source flow"
},
"source_pump": {
"name": "Source pump"
},
"summer_mode": {
"name": "Summer mode"
},
"thermostat_demand": {
"name": "Thermostat demand"
},
"three_way_valve": {
"name": "Three-way valve"
},
"user_pump": {
"name": "User pump"
}
},
"sensor": {
"compressor_speed": {
"name": "Compressor speed"
+3
View File
@@ -182,6 +182,9 @@ async def async_setup_auth( # noqa: C901
if refresh_token is None:
return False
if async_user_not_allowed_do_auth(hass, refresh_token.user, request):
return False
request[KEY_HASS_USER] = refresh_token.user
request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id
return True
+23 -24
View File
@@ -1,5 +1,4 @@
"""Support for Huawei LTE routers."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from __future__ import annotations
@@ -9,7 +8,7 @@ from contextlib import suppress
from dataclasses import dataclass, field
from datetime import timedelta
import logging
from typing import Any, NamedTuple, cast
from typing import Any, cast
from xml.parsers.expat import ExpatError
from huawei_lte_api.Client import Client
@@ -64,6 +63,7 @@ from .const import (
DEFAULT_MANUFACTURER,
DEFAULT_NOTIFY_SERVICE_NAME,
DOMAIN,
HUAWEI_LTE_CONFIG,
KEY_DEVICE_BASIC_INFORMATION,
KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL,
@@ -108,7 +108,7 @@ class Router:
"""Class for router state."""
hass: HomeAssistant
config_entry: ConfigEntry
config_entry: HuaweiLteConfigEntry
connection: Connection
url: str
@@ -278,14 +278,10 @@ class Router:
self.connection.requests_session.close()
class HuaweiLteData(NamedTuple):
"""Shared state."""
hass_config: ConfigType
routers: dict[str, Router]
type HuaweiLteConfigEntry = ConfigEntry[Router]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: HuaweiLteConfigEntry) -> bool:
"""Set up Huawei LTE component from config entry."""
url = entry.data[CONF_URL]
@@ -352,7 +348,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return False
# Store reference to router
hass.data[DOMAIN].routers[entry.entry_id] = router
entry.runtime_data = router
# Clear all subscriptions, enabled entities will push back theirs
router.subscriptions.clear()
@@ -417,7 +413,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME),
CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT),
},
hass.data[DOMAIN].hass_config,
hass.data[HUAWEI_LTE_CONFIG],
)
def _update_router(*_: Any) -> None:
@@ -440,15 +436,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: HuaweiLteConfigEntry
) -> bool:
"""Unload config entry."""
# Forward config entry unload to platforms
await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
# Forget about the router and invoke its cleanup
router = hass.data[DOMAIN].routers.pop(config_entry.entry_id)
await hass.async_add_executor_job(router.cleanup)
# Invoke router cleanup
await hass.async_add_executor_job(config_entry.runtime_data.cleanup)
return True
@@ -456,8 +453,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Huawei LTE component."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = HuaweiLteData(hass_config=config, routers={})
hass.data[HUAWEI_LTE_CONFIG] = config
def service_handler(service: ServiceCall) -> None:
"""Apply a service.
@@ -465,21 +461,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
We key this using the router URL instead of its unique id / serial number,
because the latter is not available anywhere in the UI.
"""
routers = hass.data[DOMAIN].routers
routers = [
entry.runtime_data
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
]
if url := service.data.get(CONF_URL):
router = next(
(router for router in routers.values() if router.url == url), None
)
router = next((router for router in routers if router.url == url), None)
elif not routers:
_LOGGER.error("%s: no routers configured", service.service)
return
elif len(routers) == 1:
router = next(iter(routers.values()))
router = routers[0]
else:
_LOGGER.error(
"%s: more than one router configured, must specify one of URLs %s",
service.service,
sorted(router.url for router in routers.values()),
sorted(router.url for router in routers),
)
return
if not router:
@@ -509,7 +506,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_migrate_entry(
hass: HomeAssistant, config_entry: HuaweiLteConfigEntry
) -> bool:
"""Migrate config entry to new version."""
if config_entry.version == 1:
options = dict(config_entry.options)
@@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HuaweiLteConfigEntry
from .const import (
DOMAIN,
KEY_MONITORING_CHECK_NOTIFICATIONS,
KEY_MONITORING_STATUS,
KEY_WLAN_WIFI_FEATURE_SWITCH,
@@ -30,13 +29,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
entities: list[Entity] = []
if router.data.get(KEY_MONITORING_STATUS):
@@ -11,12 +11,11 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from .const import DOMAIN
from . import HuaweiLteConfigEntry
from .entity import HuaweiLteBaseEntityWithDevice
_LOGGER = logging.getLogger(__name__)
@@ -24,13 +23,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: entity_platform.AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Huawei LTE buttons."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
buttons = [
ClearTrafficStatisticsButton(router),
RestartButton(router),
@@ -21,12 +21,7 @@ from requests.exceptions import SSLError, Timeout
from url_normalize import url_normalize
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_MAC,
CONF_NAME,
@@ -47,6 +42,7 @@ from homeassistant.helpers.service_info.ssdp import (
SsdpServiceInfo,
)
from . import HuaweiLteConfigEntry
from .const import (
CONF_MANUFACTURER,
CONF_TRACK_WIRED_CLIENTS,
@@ -76,7 +72,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
) -> HuaweiLteOptionsFlow:
"""Get options flow."""
return HuaweiLteOptionsFlow()
@@ -1,7 +1,12 @@
"""Huawei LTE constants."""
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
DOMAIN = "huawei_lte"
HUAWEI_LTE_CONFIG: HassKey[ConfigType] = HassKey(DOMAIN)
CONF_MANUFACTURER = "manufacturer"
CONF_TRACK_WIRED_CLIENTS = "track_wired_clients"
CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode"
@@ -9,7 +9,6 @@ from homeassistant.components.device_tracker import (
DOMAIN as DEVICE_TRACKER_DOMAIN,
ScannerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -17,11 +16,10 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import snakecase
from . import Router
from . import HuaweiLteConfigEntry, Router
from .const import (
CONF_TRACK_WIRED_CLIENTS,
DEFAULT_TRACK_WIRED_CLIENTS,
DOMAIN,
KEY_LAN_HOST_INFO,
KEY_WLAN_HOST_LIST,
UPDATE_SIGNAL,
@@ -50,7 +48,7 @@ def _get_hosts(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
@@ -58,9 +56,7 @@ async def async_setup_entry(
# Grab hosts list once to examine whether the initial fetch has got some data for
# us, i.e. if wlan host list is supported. Only set up a subscription and proceed
# with adding and tracking entities if it is.
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
if (hosts := _get_hosts(router, True)) is None:
return
@@ -5,10 +5,9 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from . import HuaweiLteConfigEntry
ENTRY_FIELDS_DATA_TO_REDACT = {
"mac",
@@ -74,13 +73,13 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: HuaweiLteConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{
"entry": entry.data,
"router": hass.data[DOMAIN].routers[entry.entry_id].data,
"router": entry.runtime_data.data,
},
TO_REDACT,
)
@@ -12,8 +12,7 @@ from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import Router
from .const import DOMAIN
from . import HuaweiLteConfigEntry, Router
_LOGGER = logging.getLogger(__name__)
@@ -27,9 +26,11 @@ async def async_get_service(
if discovery_info is None:
return None
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[discovery_info[ATTR_CONFIG_ENTRY_ID]]
entry: HuaweiLteConfigEntry | None = hass.config_entries.async_get_entry(
discovery_info[ATTR_CONFIG_ENTRY_ID]
)
assert entry is not None
router = entry.runtime_data
default_targets = discovery_info[CONF_RECIPIENT] or []
return HuaweiLteSmsNotificationService(router, default_targets)
@@ -22,7 +22,7 @@ rules:
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: todo
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
import logging
from typing import Any
from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum
@@ -14,14 +15,13 @@ from homeassistant.components.select import (
SelectEntity,
SelectEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Router
from .const import DOMAIN, KEY_NET_NET_MODE
from . import HuaweiLteConfigEntry, Router
from .const import KEY_NET_NET_MODE
from .entity import HuaweiLteBaseEntityWithDevice
_LOGGER = logging.getLogger(__name__)
@@ -31,18 +31,16 @@ _LOGGER = logging.getLogger(__name__)
class HuaweiSelectEntityDescription(SelectEntityDescription):
"""Class describing Huawei LTE select entities."""
setter_fn: Callable[[str], None]
setter_fn: Callable[[str], Any]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
selects: list[Entity] = []
desc = HuaweiSelectEntityDescription(
@@ -17,7 +17,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
EntityCategory,
@@ -31,9 +30,8 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import Router
from . import HuaweiLteConfigEntry, Router
from .const import (
DOMAIN,
KEY_DEVICE_INFORMATION,
KEY_DEVICE_SIGNAL,
KEY_MONITORING_CHECK_NOTIFICATIONS,
@@ -795,13 +793,11 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
sensors: list[Entity] = []
for key in SENSOR_KEYS:
if not (items := router.data.get(key)):
+4 -10
View File
@@ -10,16 +10,12 @@ from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
KEY_DIALUP_MOBILE_DATASWITCH,
KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH,
)
from . import HuaweiLteConfigEntry
from .const import KEY_DIALUP_MOBILE_DATASWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH
from .entity import HuaweiLteBaseEntityWithDevice
_LOGGER = logging.getLogger(__name__)
@@ -27,13 +23,11 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: HuaweiLteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
router = hass.data[DOMAIN].routers[config_entry.entry_id]
router = config_entry.runtime_data
switches: list[Entity] = []
if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH):
@@ -16,5 +16,5 @@
"iot_class": "local_push",
"loggers": ["bleak", "HueBLE"],
"quality_scale": "bronze",
"requirements": ["HueBLE==2.1.0"]
"requirements": ["HueBLE==2.2.2"]
}
@@ -27,7 +27,7 @@ async def async_get_media_source(hass: HomeAssistant) -> ImageUploadMediaSource:
class ImageUploadMediaSource(MediaSource):
"""Provide images as media sources."""
name: str = "Image Upload"
name: str = "Image upload"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize ImageMediaSource."""
@@ -79,7 +79,7 @@ class ImageUploadMediaSource(MediaSource):
identifier=None,
media_class=MediaClass.APP,
media_content_type="",
title="Image Upload",
title="Image upload",
can_play=False,
can_expand=True,
children_media_class=MediaClass.IMAGE,
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["indevolt-api==1.2.3"]
"requirements": ["indevolt-api==1.4.2"]
}
@@ -43,7 +43,6 @@ NUMBERS: Final = (
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
device_class=NumberDeviceClass.BATTERY,
),
IndevoltNumberEntityDescription(
key="max_ac_output_power",
+2 -4
View File
@@ -69,10 +69,8 @@ SENSORS: Final = (
IndevoltSensorEntityDescription(
key="6105",
generation=[1],
translation_key="rated_capacity",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="discharge_limit",
native_unit_of_measurement=PERCENTAGE,
),
IndevoltSensorEntityDescription(
key="2101",
@@ -223,6 +223,9 @@
"dc_output_power": {
"name": "DC output power"
},
"discharge_limit": {
"name": "[%key:component::indevolt::entity::number::discharge_limit::name%]"
},
"energy_mode": {
"name": "Energy mode",
"state": {
+4 -21
View File
@@ -1,5 +1,4 @@
"""Support for INSTEON Modems (PLM and Hub)."""
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
from contextlib import suppress
import logging
@@ -7,7 +6,7 @@ import logging
from pyinsteon import async_close, async_connect, devices
from pyinsteon.constants import ReadWriteMode
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -34,7 +33,6 @@ from .utils import (
)
_LOGGER = logging.getLogger(__name__)
OPTIONS = "options"
async def async_get_device_config(hass, config_entry):
@@ -78,12 +76,10 @@ async def close_insteon_connection(*args):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an Insteon entry."""
if dev_path := entry.options.get(CONF_DEV_PATH):
hass.data[DOMAIN] = {}
hass.data[DOMAIN][CONF_DEV_PATH] = dev_path
api.async_load_api(hass)
await api.async_register_insteon_frontend(hass)
await api.async_register_insteon_frontend(
hass, entry.options.get(CONF_DEV_PATH) or None
)
if not devices.modem:
try:
@@ -100,19 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
workdir=hass.config.config_dir, id_devices=0, load_modem_aldb=0
)
# If options existed in YAML and have not already been saved to the config entry
# add them now
if (
not entry.options
and entry.source == SOURCE_IMPORT
and hass.data.get(DOMAIN)
and hass.data[DOMAIN].get(OPTIONS)
):
hass.config_entries.async_update_entry(
entry=entry,
options=hass.data[DOMAIN][OPTIONS],
)
for device_override in entry.options.get(CONF_OVERRIDE, []):
# Override the device default capabilities for a specific address
address = device_override.get("address")
@@ -3,10 +3,11 @@
from insteon_frontend import get_build_id, locate_dir
from homeassistant.components import panel_custom, websocket_api
from homeassistant.components.frontend import async_panel_exists
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant, callback
from ..const import CONF_DEV_PATH, DOMAIN
from ..const import DOMAIN
from .aldb import (
websocket_add_default_links,
websocket_change_aldb_record,
@@ -90,11 +91,12 @@ def async_load_api(hass):
websocket_api.async_register_command(hass, websocket_get_unknown_devices)
async def async_register_insteon_frontend(hass: HomeAssistant):
async def async_register_insteon_frontend(
hass: HomeAssistant, dev_path: str | None = None
) -> None:
"""Register the Insteon frontend configuration panel."""
# Add to sidepanel if needed
if DOMAIN not in hass.data.get("frontend_panels", {}):
dev_path = hass.data.get(DOMAIN, {}).get(CONF_DEV_PATH)
if not async_panel_exists(hass, DOMAIN):
is_dev = dev_path is not None
path = dev_path or locate_dir()
build_id = get_build_id(is_dev)
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform
from homeassistant.const import CONF_SCAN_INTERVAL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -17,7 +17,6 @@ from .const import (
DEFAULT_CONSIDER_HOME,
DEFAULT_INTERFACE,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
from .router import KeeneticConfigEntry, KeeneticRouter
@@ -27,7 +26,6 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> bool:
"""Set up the component."""
hass.data.setdefault(DOMAIN, {})
async_add_defaults(hass, entry)
router = KeeneticRouter(hass, entry)
@@ -85,12 +83,8 @@ async def async_unload_entry(
return unload_ok
def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry):
def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None:
"""Populate default options."""
host: str = entry.data[CONF_HOST]
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {})
options = {
CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL,
CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME,
@@ -98,7 +92,6 @@ def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry):
CONF_TRY_HOTSPOT: True,
CONF_INCLUDE_ARP: True,
CONF_INCLUDE_ASSOCIATED: True,
**imported_options,
**entry.options,
}
@@ -198,6 +198,8 @@ class KeeneticOptionsFlowHandler(OptionsFlowWithReload):
options = vol.Schema(
{
# Polling interval is user-configurable, which is no longer allowed
# pylint: disable-next=hass-config-flow-polling-field
vol.Required(
CONF_SCAN_INTERVAL,
default=self.config_entry.options.get(
@@ -62,6 +62,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
Platform.RADIO_FREQUENCY,
Platform.SENSOR,
Platform.SWITCH,
Platform.WEATHER,

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