Compare commits

...

150 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
6eb40574bc tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 19:39:19 +01:00
Daniel Hjelseth Høyer
4fd1ef5483 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 17:49:18 +01:00
Daniel Hjelseth Høyer
7ec5d5305d Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:38:01 +01:00
Daniel Hjelseth Høyer
7f31d2538e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:08:45 +01:00
Daniel Hjelseth Høyer
e1943307cf Merge branch 'dev' into tibber_data 2025-11-16 16:08:21 +01:00
Daniel Hjelseth Høyer
a06529d187 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 15:59:18 +01:00
Jan Bouwhuis
a0e0549d90 Fix missing temperature_delta device class translations (#156685) 2025-11-16 15:00:51 +01:00
epenet
ba034c6c8c Add alarm_state to Tuya siren alarm (#151221) 2025-11-16 12:26:33 +01:00
Åke Strandberg
008bb85c59 Mock arguments in senz tests (#156677) 2025-11-16 12:25:28 +01:00
Michael
cf1c1294d3 Bump adguardhome to 0.8.1 (#156679) 2025-11-16 12:16:17 +01:00
Daniel Hjelseth Høyer
21554af6a1 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 12:14:03 +01:00
Åke Strandberg
11d5d314cc Fix type hints in miele tests (#156657) 2025-11-16 12:12:45 +01:00
Joost Lekkerkerker
6f0de3071a Add fixture for dual washing machine to SmartThings (#156646) 2025-11-16 11:43:25 +01:00
mettolen
87d2597292 Add diagnostics to Saunum integration (#156623) 2025-11-16 11:40:49 +01:00
Manu
437bc04fe8 Remove Live-TV support from Xbox integration (#156669) 2025-11-16 11:30:04 +01:00
Åke Strandberg
67a0d6a187 Mock arguments to ClientResponseError() in miele tests (#156676) 2025-11-16 10:03:32 +01:00
Lukas
abb52bca81 Add more sensors to Pooldose (#156002)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 23:02:27 +01:00
J. Nick Koston
d2d6889278 Bump thermopro-ble to 1.1.2 (#156652) 2025-11-15 14:10:59 -06:00
David Rapan
bdca592219 Add Shelly event translation (#156162)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-15 20:52:13 +02:00
Michael
5c0c7b9ec3 Bump adguardhome to 0.8.0 (#156651) 2025-11-15 16:26:05 +01:00
TheDK
9717599fb9 Use SensorDeviceClass.PRESSURE in Withings (#156648)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-15 16:08:34 +01:00
MarkGodwin
4d7de2f814 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-15 15:39:56 +01:00
epenet
779590ce1c Migrate Tuya climate (humidity) to use wrapper class (#156575) 2025-11-15 15:05:52 +01:00
epenet
f3a185ff9c Migrate Tuya cover to use wrapper class (#156558)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:04:54 +01:00
epenet
5a5a106984 Migrate Tuya humidifier to use wrapper class (#156572)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:03:30 +01:00
epenet
796b421d99 Migrate Tuya vacuum to use wrapper class (#156569) 2025-11-15 15:02:49 +01:00
epenet
0c03e8dbe9 Migrate Tuya light (color_mode) to use wrapper class (#156582) 2025-11-15 15:02:40 +01:00
epenet
47cf4e3ffe Ensure Tuya scale and step are integers (#156555) 2025-11-15 14:30:44 +02:00
epenet
0ea0fc151d Use parametrize in tuya climate tests (#156577) 2025-11-15 14:28:57 +02:00
Åke Strandberg
b7e5afec9f Fix typing in miele tests (#156637) 2025-11-15 12:19:34 +01:00
cdnninja
7a2bb67e82 Refactor vesync test (#156625) 2025-11-15 09:24:48 +01:00
Manu
e0612bec07 Bump pythonkuma to v0.3.2 (#156626) 2025-11-15 03:43:16 +01:00
Denis Shulyaka
a06f4b6776 Anthropic model selection from list (#156261) 2025-11-14 21:16:52 -05:00
Denis Shulyaka
275670a526 Add support for gpt-5.1 (#156612) 2025-11-14 18:39:05 -05:00
Robert Resch
d0d62526dd Bump async-upnp-client to 0.46.0 (#156622) 2025-11-14 18:30:18 -05:00
Franck Nijhof
aefdf412b0 Extract device template functions into a devices Jinja2 extension (#156619) 2025-11-15 00:23:38 +01:00
Manu
56ab6b2512 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 22:07:13 +01:00
mettolen
d1dea85cf5 Add Saunum integration (#155099)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-14 19:55:55 +01:00
wollew
84b0d39763 clean up velux test fixtures (#156554) 2025-11-14 19:53:17 +01:00
tronikos
3aff225bc3 Add Google Weather integration (#147015)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 19:46:56 +01:00
Daniel Hjelseth Høyer
b4aae93c45 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 19:18:22 +01:00
Ludovic BOUÉ
04458e01be Fix spelling of 'Auto-relock time' in Matter integration strings (#156607) 2025-11-14 19:10:57 +01:00
Thomas55555
ae51cfb8c0 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 19:10:16 +01:00
Simone Chemelli
c116a9c037 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 19:09:19 +01:00
Allen Porter
fb58758684 Add completed timestamp support in Google tasks (#156564) 2025-11-14 12:07:23 -05:00
Franck Nijhof
25fbcbc68c Extract floor template functions into a floors Jinja2 extension (#156589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:06:55 -05:00
Denis Shulyaka
a670286b45 Bump openai to 2.8.0 (#156602) 2025-11-14 12:06:21 -05:00
ElectricSteve
52ba55b17f Bump youtubeaio to 2.1.0 (#156595) 2025-11-14 16:01:00 +01:00
epenet
ff0fc98c36 Fix sfr_box entry reload (#156593) 2025-11-14 15:03:43 +01:00
David Rapan
9f78a2263d Move Shelly sensor get_entity_translation_attributes to utils (#156590)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-14 15:44:48 +02:00
Kamil Breguła
9b4696a80b Add quality_scale to mvglive manifest (#155474) 2025-11-14 13:20:10 +01:00
wollew
70fe8cae39 Fix velux scenes (naming and unique ids) (#156436) 2025-11-14 13:18:08 +01:00
wollew
95eb45ab08 cleanup registered callbacks before removing velux config entry (#156525) 2025-11-14 13:06:45 +01:00
Erwin Douna
84f8e57141 Add retry_after to UpdateFailed in update coordinator (#153550)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:19:02 +01:00
Franck Nijhof
f484b6df0d Extract label template functions into a label Jinja2 extension (#156439)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:04:31 +01:00
J. Diego Rodríguez Royo
34c1d45ee0 Ensure that Home Connect program update value event is a string when updating options (#156416)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 10:51:52 +01:00
epenet
09a105d9ad Migrate Tuya light (switch) to use wrapper class (#156580) 2025-11-14 10:33:58 +01:00
epenet
6bd1787d0a Improve parametrize in tuya light tests (#156581) 2025-11-14 10:19:48 +01:00
David Rapan
37040f5064 Add Shelly switch translation (#156146)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:16:04 +02:00
puddly
531397ec07 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 09:40:46 +01:00
epenet
d6cc0f81de Remove pointless super.async_added_to_hass in Tuya climate (#156573) 2025-11-14 09:31:03 +01:00
TheJulianJES
f8ef8a466a Bump ZHA to 0.0.79 (#156571) 2025-11-14 09:30:38 +01:00
Åke Strandberg
713015e26a Improve logging of failing miele action commands (#156275) 2025-11-14 09:10:24 +01:00
Åke Strandberg
f9c1e81c5e Improve error handling and add tests to senz climate (#156544) 2025-11-14 09:06:56 +01:00
Joost Lekkerkerker
0549d113e6 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 08:48:47 +01:00
dependabot[bot]
0d842978ec Bump github/codeql-action from 4.31.2 to 4.31.3 (#156565) 2025-11-13 23:05:52 -08:00
Daniel Hjelseth Høyer
1f9c244c5c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 06:01:05 +01:00
Nojus
55476ef6ea Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 00:14:48 +01:00
starkillerOG
0e130d8fdd Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 00:12:03 +01:00
slickm0nty
20bcb84956 Set suggested display precision in modbus integration (#155467) 2025-11-13 22:47:40 +00:00
Daniel Hjelseth Høyer
9fa1b1b8df Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 22:11:18 +01:00
jlanchares
bbb1d57081 Goodwe port502ftp support with PORT stored on config data. (#148628)
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
2025-11-13 21:12:47 +01:00
hanwg
121406569b Upgrade Telegram bot quality scale to Silver (#155352) 2025-11-13 21:08:35 +01:00
Joost Lekkerkerker
4866c775ce Fix CI (#156549) 2025-11-13 21:08:08 +01:00
Daniel Hjelseth Høyer
f3ac3ecf05 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 21:07:27 +01:00
Willem-Jan van Rootselaar
7c5ab12270 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-13 20:44:12 +01:00
Erik Montnemery
099edfac20 Fix flux_led tests opening sockets (#156458) 2025-11-13 20:39:56 +01:00
epenet
aa31df0fd5 Migrate Tuya camera to use wrapper class (#156542) 2025-11-13 20:38:44 +01:00
Bram Kragten
13fbeb6cdb Add support for trigger and condition category icons (#156533) 2025-11-13 14:36:34 -05:00
karwosts
8d557447df Add completed timestamp to TodoItem (#156547)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-11-13 14:35:33 -05:00
Joakim Sørensen
e6e3f2455f Add discovery_service_actions configuration option (#156537) 2025-11-13 14:35:22 -05:00
epenet
c9c518ee84 Improve IntegerTypeData scaling in Tuya (#156507) 2025-11-13 20:29:07 +01:00
Magnus
214731e964 Component asuswrt: Type check is redundant for this value (#154535) 2025-11-13 20:24:24 +01:00
Daniel Hjelseth Høyer
9477b2206b Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 20:07:57 +01:00
Jan Čermák
c4b09c9a0a Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-13 20:01:22 +01:00
epenet
f5b5b2fb70 Remove unused/absent property from Tuya (#156508) 2025-11-13 19:32:08 +01:00
Manu
bb3cdd382b Add media_content_id to media player in Xbox integration (#156519) 2025-11-13 19:25:43 +01:00
starkillerOG
8d09b5c273 Relax Reolink update interval and timeout for big installs (#156509) 2025-11-13 19:25:03 +01:00
epenet
d92fa7fa72 Move more logic from entity to wrapper in Tuya alarm (#156450) 2025-11-13 18:37:47 +01:00
Åke Strandberg
0c45b7f615 Add reconfiguration flow to senz (#156539) 2025-11-13 16:49:16 +01:00
Alexandre CUER
bfa1116115 Add quality scale to Emoncms (#149727)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 16:10:29 +01:00
Arie Catsman
4984237987 Add alternative ct meter source to enphase_envoy diagnostics (#154468) 2025-11-13 15:37:36 +01:00
Joost Lekkerkerker
3839573151 Bump pySmartThings to 3.3.3 (#156528) 2025-11-13 15:31:03 +01:00
Åke Strandberg
e02dc53df3 Add reauthentication flow and tests to senz (#156534) 2025-11-13 15:28:45 +01:00
Arie Catsman
bedae1e12c Optimize Enphase_Envoy CT sensor entity code (#153859) 2025-11-13 14:59:24 +01:00
epenet
b4eb73be98 Improve tests for Tuya alarm control panel (#156481) 2025-11-13 14:44:38 +01:00
wollew
0ac3f776fa set shorthand atrributes for supported_features in velux cover (#156524) 2025-11-13 14:18:20 +01:00
Petar Petrov
8e8a4fff11 Extract grid, gas, and water source validation into separate functions (#156515) 2025-11-13 13:28:25 +01:00
Åke Strandberg
579ffcc64d Add unique_id to senz config_entry (#156472) 2025-11-13 12:26:33 +01:00
Foscam-wangzhengyu
81943fb31d URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 12:23:28 +01:00
Petro31
70dd0bf12e Modernize template alarm control panel (#156476) 2025-11-13 12:21:03 +01:00
Tom Matheussen
c2d462c1e7 Refactor Satel Integra platforms to use shared base entity (#156499) 2025-11-13 12:20:32 +01:00
epenet
49e050cc60 Redact more DP codes in tuya diagnostics (#156497) 2025-11-13 12:18:43 +01:00
Josef Zweck
f6d829a2f3 Bump pylamarzocco to 2.1.3 (#156501) 2025-11-13 11:54:15 +01:00
Aarni Koskela
e44e3b6f25 Rename RuuviTag BLE to Ruuvi BLE (#156504) 2025-11-13 11:36:50 +01:00
Christopher Fenner
af603661c0 Fix spelling in ViCare integration (#156500) 2025-11-13 10:54:55 +01:00
puddly
35c6113777 Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-13 09:25:00 +01:00
TheJulianJES
3c2f729ddc Fix Z-Wave generating name before setting entity description (#156494) 2025-11-13 08:18:22 +01:00
Erik Montnemery
0d63cb765f Fix lg_netcast tests opening sockets (#156459)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 07:44:56 +01:00
TheJulianJES
3cb414511b Migrate Z-Wave event entity to new discovery schema (#156320) 2025-11-13 07:22:37 +01:00
karwosts
f55c36d42d Update ical to 11.1.0 (#156487) 2025-11-12 20:24:04 -08:00
Erik Montnemery
26bb301cc0 Fix lifx tests opening sockets (#156460) 2025-11-12 21:51:54 +02:00
Erik Montnemery
4159e483ee Fix wiz tests opening sockets (#156468) 2025-11-12 20:11:15 +01:00
Erik Montnemery
7eb6f7cc07 Fix romy tests opening sockets (#156466) 2025-11-12 20:10:46 +01:00
epenet
a7d01b0b03 Use json_loads_object in tuya models (#156455) 2025-11-12 20:08:28 +01:00
epenet
1e5cfddf83 Use json_loads_object in Tuya light (#156452) 2025-11-12 19:34:17 +01:00
epenet
006fc5b10a Remove JSON parsing from tuya diagnostics (#156451) 2025-11-12 19:32:40 +01:00
Erik Montnemery
35a4b685b3 Fix steamist tests opening sockets (#156467) 2025-11-12 12:01:21 -06:00
Janez Urevc
b166818ef4 Bump tesla-wall-connector to 1.1.0 (#156438) 2025-11-12 17:45:08 +01:00
Erik Montnemery
34cd9f11d0 Fix onkyo tests opening sockets (#156461) 2025-11-12 17:32:58 +01:00
Erik Montnemery
0711d62085 Change collation to utf8mb4_bin for MySQL and MariaDB databases (#156297)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-12 16:54:58 +01:00
J. Diego Rodríguez Royo
f70aeafb5f Bump aiohomeconnect to version 0.23.1 (#156454) 2025-11-12 15:59:20 +01:00
MoonDevLT
e2279b3589 Bump lunatone-rest-api-client to 0.5.7 (#156356) 2025-11-12 14:44:52 +01:00
Christopher Fenner
87b68e99ec Add compressor, condensor and evaporator sensors in ViCare integration (#156411) 2025-11-12 14:42:26 +01:00
Manu
b6c8b787e8 Add device storage sensor entities to Xbox (#155657) 2025-11-12 13:53:42 +01:00
Franck Nijhof
78f26edc29 Extend base jinja2 extension with limited template errors (#156431) 2025-11-12 13:52:15 +01:00
ehendrix23
5e6a72de90 Bump pyecobee to 0.3.2 (#156421)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 13:40:08 +01:00
Erik Montnemery
dcc559f8b6 Fix progress step bugs (#155923) 2025-11-12 13:14:53 +01:00
Manu
eda49cced0 Code quality improvements for Xbox integration (#156395) 2025-11-12 14:09:53 +02:00
Josef Zweck
14e41ab119 Fix lamarzocco update status (#156442) 2025-11-12 13:10:23 +02:00
Timothy
46151456d8 Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-12 12:03:05 +01:00
cdnninja
39773a022a Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 11:59:45 +01:00
Christopher Fenner
5f49a6450f Add air quality sensors in ViCare integration (#156417) 2025-11-12 11:45:04 +01:00
Christopher Fenner
dc8425c580 Add icon for pm4 sensor (#156432) 2025-11-12 11:38:33 +01:00
Josef Zweck
910bd371e4 Remove wsproto from exceptions (#156434) 2025-11-12 11:16:36 +01:00
Tom Matheussen
802a225e11 Clean alarm control panel platform for Satel Integra (#156357) 2025-11-12 11:09:48 +01:00
Josef Zweck
84f66fa689 Fix aussie-broadband tests (#156441) 2025-11-12 10:54:23 +01:00
wollew
0b7e88d0e0 add parallel_updates for button entity (#156437) 2025-11-12 11:49:32 +02:00
puddly
1fcaf95df5 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-12 10:44:33 +01:00
Erik Montnemery
6c7434531f Fix tado tests opening sockets (#156386) 2025-11-12 10:08:15 +01:00
Åke Strandberg
5ec1c2b68b Use runtime_data in Senz (#156408) 2025-11-12 10:06:45 +01:00
Christopher Fenner
d8636d8346 Bump PyViCare to 2.55.0 (#156426) 2025-11-12 09:57:49 +01:00
Brett Adams
434763c74d Fix update progress in Teslemetry (#156422) 2025-11-12 09:55:09 +01:00
Petar Petrov
8cd2c1b43b Add power configuration to Energy dashboard (#153809)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 09:21:33 +01:00
Daniel Hjelseth Høyer
44711787a4 Update pyMill to 0.14.1 (#156396) 2025-11-12 09:15:59 +01:00
TheJulianJES
98fd0ee683 Exempt wsproto from license check (#156418) 2025-11-12 08:45:11 +01:00
Joost Lekkerkerker
303e4ce961 Add mac address to Velux device (#156376) 2025-11-12 09:45:02 +02:00
Paul Bottein
76f29298cd Add home panel (#156269) 2025-11-12 09:09:39 +02:00
Will Moss
17f5d0a69f Use common string for the remaining oauth2 error messages (#156407) 2025-11-12 04:43:12 +01:00
johanzander
90561de438 Refactor Growatt Server integration tests (#156413)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 00:32:25 +01:00
415 changed files with 31223 additions and 10451 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean type: boolean
env: env:
CACHE_VERSION: 1 CACHE_VERSION: 2
UV_CACHE_VERSION: 1 UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12" HA_SHORT_VERSION: "2025.12"

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
homeassistant.components.google_drive.* homeassistant.components.google_drive.*
homeassistant.components.google_photos.* homeassistant.components.google_photos.*
homeassistant.components.google_sheets.* homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.* homeassistant.components.govee_ble.*
homeassistant.components.gpsd.* homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.* homeassistant.components.greeneye_monitor.*

4
CODEOWNERS generated
View File

@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter /tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger /homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger
/homeassistant/components/google_weather/ @tronikos
/tests/components/google_weather/ @tronikos
/homeassistant/components/govee_ble/ @bdraco /homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco /tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen /homeassistant/components/govee_light_local/ @Galorhallen
@@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor
/tests/components/sanix/ @tomaszsluszniak /tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen /homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen /tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/saunum/ @mettolen
/tests/components/saunum/ @mettolen
/homeassistant/components/scene/ @home-assistant/core /homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core /homeassistant/components/schedule/ @home-assistant/core

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
cosign: cosign:
base_identity: https://github.com/home-assistant/docker/.* base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.* identity: https://github.com/home-assistant/core/.*

View File

@@ -15,6 +15,7 @@
"google_tasks", "google_tasks",
"google_translate", "google_translate",
"google_travel_time", "google_travel_time",
"google_weather",
"google_wifi", "google_wifi",
"google", "google",
"nest", "nest",

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["adguardhome"], "loggers": ["adguardhome"],
"requirements": ["adguardhome==0.7.0"] "requirements": ["adguardhome==0.8.1"]
} }

View File

@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title, name=entry.title,
config_entry=entry, config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL), update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
) )
self.api = AmazonEchoApi( self.api = AmazonEchoApi(
session, session,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from functools import partial from functools import partial
import json import json
import logging import logging
import re
from typing import Any from typing import Any
import anthropic import anthropic
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional( vol.Optional(
CONF_CHAT_MODEL, CONF_CHAT_MODEL,
default=RECOMMENDED_CHAT_MODEL, default=RECOMMENDED_CHAT_MODEL,
): str, ): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
),
vol.Optional( vol.Optional(
CONF_MAX_TOKENS, CONF_MAX_TOKENS,
default=RECOMMENDED_MAX_TOKENS, default=RECOMMENDED_MAX_TOKENS,
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True, last_step=True,
) )
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
async def _get_location_data(self) -> dict[str, str]: async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user.""" """Get approximate location data of the user."""
location_data: dict[str, str] = {} location_data: dict[str, str] = {}

View File

@@ -111,8 +111,6 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
if isinstance(data, dict): if isinstance(data, dict):
return dict(zip(keys, list(data.values()), strict=False)) return dict(zip(keys, list(data.values()), strict=False))
if not isinstance(data, (list, tuple)):
raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data, strict=False)) return dict(zip(keys, data, strict=False))
return _wrapper return _wrapper

View File

@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.fast_coordinator, data) super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
self._attr_min_temp = data.static.min_temp.value # Set temperature range if available, otherwise use Home Assistant defaults
self._attr_max_temp = data.static.max_temp.value if data.static.min_temp is not None and data.static.min_temp.value is not None:
self._attr_min_temp = data.static.min_temp.value
if data.static.max_temp is not None and data.static.max_temp.value is not None:
self._attr_max_temp = data.static.max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property @property

View File

@@ -7,7 +7,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["bsblan"], "loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.0"], "requirements": ["python-bsblan==3.1.1"],
"zeroconf": [ "zeroconf": [
{ {
"name": "bsb-lan*", "name": "bsb-lan*",

View File

@@ -55,6 +55,7 @@ from .const import (
CONF_ALIASES, CONF_ALIASES,
CONF_API_SERVER, CONF_API_SERVER,
CONF_COGNITO_CLIENT_ID, CONF_COGNITO_CLIENT_ID,
CONF_DISCOVERY_SERVICE_ACTIONS,
CONF_ENTITY_CONFIG, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_FILTER,
CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS,
@@ -139,6 +140,7 @@ CONFIG_SCHEMA = vol.Schema(
{ {
vol.Required(CONF_MODE): vol.In([MODE_DEV]), vol.Required(CONF_MODE): vol.In([MODE_DEV]),
vol.Required(CONF_API_SERVER): str, vol.Required(CONF_API_SERVER): str,
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
} }
), ),
_BASE_CONFIG_SCHEMA.extend( _BASE_CONFIG_SCHEMA.extend(

View File

@@ -79,6 +79,7 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server" CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server" CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server" CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
CONF_RELAYER_SERVER = "relayer_server" CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["async_upnp_client"], "loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms", "documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["async-upnp-client==0.45.0"], "requirements": ["async-upnp-client==0.46.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1", "deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -9,7 +9,7 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyecobee"], "loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.2.20"], "requirements": ["python-ecobee-api==0.3.2"],
"single_config_entry": true, "single_config_entry": true,
"zeroconf": [ "zeroconf": [
{ {

View File

@@ -0,0 +1,84 @@
rules:
# todo : add get_feed_list to the library
# todo : see if we can drop some extra attributes
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
test_reconfigure_api_error should use a mock config entry fixture
test_user_flow_failure should use a mock config entry fixture
move test_user_flow_* to the top of the file
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
No events are explicitly registered by the integration.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage:
status: todo
comment: |
test the entry state in test_failure
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples:
status: exempt
comment: |
This integration does not provide any automation
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: change device_class=SensorDeviceClass.SIGNAL_STRENGTH to SOUND_PRESSURE
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import Counter from collections import Counter
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Literal, TypedDict from typing import Literal, NotRequired, TypedDict
import voluptuous as vol import voluptuous as vol
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
class FlowFromGridSourceType(TypedDict): class FlowFromGridSourceType(TypedDict):
"""Dictionary describing the 'from' stat for the grid source.""" """Dictionary describing the 'from' stat for the grid source."""
# statistic_id of a an energy meter (kWh) # statistic_id of an energy meter (kWh)
stat_energy_from: str stat_energy_from: str
# statistic_id of costs ($) incurred from the energy meter # statistic_id of costs ($) incurred from the energy meter
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
number_energy_price: float | None # Price for energy ($/kWh) number_energy_price: float | None # Price for energy ($/kWh)
class GridPowerSourceType(TypedDict):
"""Dictionary holding the source of grid power consumption."""
# statistic_id of a power meter (kW)
# negative values indicate grid return
stat_rate: str
class GridSourceType(TypedDict): class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption.""" """Dictionary holding the source of grid energy consumption."""
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
flow_from: list[FlowFromGridSourceType] flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType] flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]
cost_adjustment_day: float cost_adjustment_day: float
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"] type: Literal["solar"]
stat_energy_from: str stat_energy_from: str
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None config_entry_solar_forecast: list[str] | None
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str stat_energy_from: str
stat_energy_to: str stat_energy_to: str
# positive when discharging, negative when charging
stat_rate: NotRequired[str]
class GasSourceType(TypedDict): class GasSourceType(TypedDict):
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value # This is an ever increasing value
stat_consumption: str stat_consumption: str
# Instantaneous rate of flow: W, L/min or m³/h
stat_rate: NotRequired[str]
# An optional custom name for display in energy graphs # An optional custom name for display in energy graphs
name: str | None name: str | None
# An optional statistic_id identifying a device # An optional statistic_id identifying a device
# that includes this device's consumption in its total # that includes this device's consumption in its total
included_in_stat: str | None included_in_stat: NotRequired[str]
class EnergyPreferences(TypedDict): class EnergyPreferences(TypedDict):
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
} }
) )
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]: def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once.""" """Generate a validator that ensures a value is only used once."""
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
[FLOW_TO_GRID_SOURCE_SCHEMA], [FLOW_TO_GRID_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_energy_to"), _generate_unique_value_validator("stat_energy_to"),
), ),
vol.Optional("power"): vol.All(
[GRID_POWER_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_rate"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float), vol.Required("cost_adjustment_day"): vol.Coerce(float),
} }
) )
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{ {
vol.Required("type"): "solar", vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str, vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
} }
) )
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery", vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str, vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str, vol.Required("stat_energy_to"): str,
vol.Optional("stat_rate"): str,
} }
) )
GAS_SOURCE_SCHEMA = vol.Schema( GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema( DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{ {
vol.Required("stat_consumption"): str, vol.Required("stat_consumption"): str,
vol.Optional("stat_rate"): str,
vol.Optional("name"): str, vol.Optional("name"): str,
vol.Optional("included_in_stat"): str, vol.Optional("included_in_stat"): str,
} }

View File

@@ -12,6 +12,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE, STATE_UNAVAILABLE,
STATE_UNKNOWN, STATE_UNKNOWN,
UnitOfEnergy, UnitOfEnergy,
UnitOfPower,
UnitOfVolume, UnitOfVolume,
) )
from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
} }
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
ENERGY_PRICE_UNITS = tuple( ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
) )
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy" ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price" ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
GAS_USAGE_DEVICE_CLASSES = ( GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY, sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS, sensor.SensorDeviceClass.GAS,
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
), ),
} }
if issue_type == POWER_UNIT_ERROR:
return {
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
}
if issue_type == GAS_UNIT_ERROR: if issue_type == GAS_UNIT_ERROR:
return { return {
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]), "energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
@callback @callback
def _async_validate_usage_stat( def _async_validate_stat_common(
hass: HomeAssistant, hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]], metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str, stat_id: str,
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
allowed_units: Mapping[str, Sequence[str]], allowed_units: Mapping[str, Sequence[str]],
unit_error: str, unit_error: str,
issues: ValidationIssues, issues: ValidationIssues,
) -> None: check_negative: bool = False,
"""Validate a statistic.""" ) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
if stat_id not in metadata: if stat_id not in metadata:
issues.add_issue(hass, "statistics_not_defined", stat_id) issues.add_issue(hass, "statistics_not_defined", stat_id)
has_entity_source = valid_entity_id(stat_id) has_entity_source = valid_entity_id(stat_id)
if not has_entity_source: if not has_entity_source:
return return None
entity_id = stat_id entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id): if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id) issues.add_issue(hass, "recorder_untracked", entity_id)
return return None
if (state := hass.states.get(entity_id)) is None: if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id) issues.add_issue(hass, "entity_not_defined", entity_id)
return return None
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state) issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return return None
try: try:
current_value: float | None = float(state.state) current_value: float | None = float(state.state)
except ValueError: except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state) issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return return None
if current_value is not None and current_value < 0: if check_negative and current_value is not None and current_value < 0:
issues.add_issue(hass, "entity_negative_state", entity_id, current_value) issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
device_class = state.attributes.get(ATTR_DEVICE_CLASS) device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
if device_class and unit not in allowed_units.get(device_class, []): if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit) issues.add_issue(hass, unit_error, entity_id, unit)
return entity_id
@callback
def _async_validate_usage_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=True,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS) state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
allowed_state_classes = [ allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
issues.add_issue(hass, unit_error, entity_id, unit) issues.add_issue(hass, unit_error, entity_id, unit)
@callback
def _async_validate_power_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a power statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=False,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
if state_class != sensor.SensorStateClass.MEASUREMENT:
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
@callback @callback
def _async_validate_cost_stat( def _async_validate_cost_stat(
hass: HomeAssistant, hass: HomeAssistant,
@@ -309,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
issues.add_issue(hass, "recorder_untracked", cost_entity_id) issues.add_issue(hass, "recorder_untracked", cost_entity_id)
def _validate_grid_source(
hass: HomeAssistant,
source: data.GridSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate grid energy source."""
flow_from: data.FlowFromGridSourceType
for flow_from in source["flow_from"]:
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_from["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow_from.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_from.get("entity_energy_price") is not None
or flow_from.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_from["stat_energy_from"],
source_result,
)
)
flow_to: data.FlowToGridSourceType
for flow_to in source["flow_to"]:
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_to["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_to.get("entity_energy_price") is not None
or flow_to.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_to["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
def _validate_gas_source(
hass: HomeAssistant,
source: data.GasSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate gas energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
source: data.WaterSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate water energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation: async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration.""" """Validate the energy configuration."""
manager: data.EnergyManager = await data.async_get_manager(hass) manager: data.EnergyManager = await data.async_get_manager(hass)
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {} statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
validate_calls = [] validate_calls: list[functools.partial[None]] = []
wanted_statistics_metadata: set[str] = set() wanted_statistics_metadata: set[str] = set()
result = EnergyPreferencesValidation() result = EnergyPreferencesValidation()
@@ -327,215 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
result.energy_sources.append(source_result) result.energy_sources.append(source_result)
if source["type"] == "grid": if source["type"] == "grid":
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType _validate_grid_source(
for flow in source["flow_from"]: hass,
wanted_statistics_metadata.add(flow["stat_energy_from"]) source,
validate_calls.append( statistics_metadata,
functools.partial( wanted_statistics_metadata,
_async_validate_usage_stat, source_result,
hass, validate_calls,
statistics_metadata, )
flow["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_from"],
source_result,
)
)
for flow in source["flow_to"]:
wanted_statistics_metadata.add(flow["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_to"],
source_result,
)
)
elif source["type"] == "gas": elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"]) _validate_gas_source(
validate_calls.append( hass,
functools.partial( source,
_async_validate_usage_stat, statistics_metadata,
hass, wanted_statistics_metadata,
statistics_metadata, source_result,
source["stat_energy_from"], validate_calls,
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
) )
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "water": elif source["type"] == "water":
wanted_statistics_metadata.add(source["stat_energy_from"]) _validate_water_source(
validate_calls.append( hass,
functools.partial( source,
_async_validate_usage_stat, statistics_metadata,
hass, wanted_statistics_metadata,
statistics_metadata, source_result,
source["stat_energy_from"], validate_calls,
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
) )
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "solar": elif source["type"] == "solar":
wanted_statistics_metadata.add(source["stat_energy_from"]) wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append( validate_calls.append(

View File

@@ -147,6 +147,8 @@ async def async_get_config_entry_diagnostics(
"ctmeter_production_phases": envoy_data.ctmeter_production_phases, "ctmeter_production_phases": envoy_data.ctmeter_production_phases,
"ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases, "ctmeter_consumption_phases": envoy_data.ctmeter_consumption_phases,
"ctmeter_storage_phases": envoy_data.ctmeter_storage_phases, "ctmeter_storage_phases": envoy_data.ctmeter_storage_phases,
"ctmeters": envoy_data.ctmeters,
"ctmeters_phases": envoy_data.ctmeters_phases,
"dry_contact_status": envoy_data.dry_contact_status, "dry_contact_status": envoy_data.dry_contact_status,
"dry_contact_settings": envoy_data.dry_contact_settings, "dry_contact_settings": envoy_data.dry_contact_settings,
"inverters": envoy_data.inverters, "inverters": envoy_data.inverters,
@@ -179,6 +181,7 @@ async def async_get_config_entry_diagnostics(
"ct_consumption_meter": envoy.consumption_meter_type, "ct_consumption_meter": envoy.consumption_meter_type,
"ct_production_meter": envoy.production_meter_type, "ct_production_meter": envoy.production_meter_type,
"ct_storage_meter": envoy.storage_meter_type, "ct_storage_meter": envoy.storage_meter_type,
"ct_meters": list(envoy_data.ctmeters.keys()),
} }
fixture_data: dict[str, Any] = {} fixture_data: dict[str, Any] = {}

View File

@@ -399,330 +399,189 @@ class EnvoyCTSensorEntityDescription(SensorEntityDescription):
cttype: str | None = None cttype: str | None = None
CT_NET_CONSUMPTION_SENSORS = ( # All ct types unified in common setup
EnvoyCTSensorEntityDescription( CT_SENSORS = (
key="lifetime_net_consumption", [
translation_key="lifetime_net_consumption", EnvoyCTSensorEntityDescription(
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, key=key,
state_class=SensorStateClass.TOTAL_INCREASING, translation_key=key,
device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3, device_class=SensorDeviceClass.ENERGY,
value_fn=attrgetter("energy_delivered"), suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
on_phase=None, suggested_display_precision=3,
cttype=CtType.NET_CONSUMPTION, value_fn=attrgetter("energy_delivered"),
), on_phase=None,
EnvoyCTSensorEntityDescription( cttype=cttype,
key="lifetime_net_production", )
translation_key="lifetime_net_production", for cttype, key in (
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, (CtType.NET_CONSUMPTION, "lifetime_net_consumption"),
state_class=SensorStateClass.TOTAL_INCREASING, # Production CT energy_delivered is not used
device_class=SensorDeviceClass.ENERGY, (CtType.STORAGE, "lifetime_battery_discharged"),
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption",
translation_key="net_consumption",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="frequency",
translation_key="net_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="voltage",
translation_key="net_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_current",
translation_key="net_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_ct_powerfactor",
translation_key="net_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_metering_status",
translation_key="net_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
EnvoyCTSensorEntityDescription(
key="net_consumption_ct_status_flags",
translation_key="net_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.NET_CONSUMPTION,
),
)
CT_NET_CONSUMPTION_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [
replace(
sensor,
key=f"{sensor.key}_l{phase + 1}",
translation_key=f"{sensor.translation_key}_phase",
entity_registry_enabled_default=False,
on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"},
) )
for sensor in list(CT_NET_CONSUMPTION_SENSORS)
] ]
for phase in range(3) + [
} EnvoyCTSensorEntityDescription(
key=key,
CT_PRODUCTION_SENSORS = ( translation_key=key,
EnvoyCTSensorEntityDescription( native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
key="production_ct_frequency", state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="production_ct_frequency", device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfFrequency.HERTZ, suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=3,
device_class=SensorDeviceClass.FREQUENCY, value_fn=attrgetter("energy_received"),
suggested_display_precision=1, on_phase=None,
entity_registry_enabled_default=False, cttype=cttype,
value_fn=attrgetter("frequency"), )
on_phase=None, for cttype, key in (
cttype=CtType.PRODUCTION, (CtType.NET_CONSUMPTION, "lifetime_net_production"),
), # Production CT energy_received is not used
EnvoyCTSensorEntityDescription( (CtType.STORAGE, "lifetime_battery_charged"),
key="production_ct_voltage", )
translation_key="production_ct_voltage", ]
native_unit_of_measurement=UnitOfElectricPotential.VOLT, + [
state_class=SensorStateClass.MEASUREMENT, EnvoyCTSensorEntityDescription(
device_class=SensorDeviceClass.VOLTAGE, key=key,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, translation_key=key,
suggested_display_precision=1, native_unit_of_measurement=UnitOfPower.WATT,
entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT,
value_fn=attrgetter("voltage"), device_class=SensorDeviceClass.POWER,
on_phase=None, suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
cttype=CtType.PRODUCTION, suggested_display_precision=3,
), value_fn=attrgetter("active_power"),
EnvoyCTSensorEntityDescription( on_phase=None,
key="production_ct_current", cttype=cttype,
translation_key="production_ct_current", )
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, for cttype, key in (
state_class=SensorStateClass.MEASUREMENT, (CtType.NET_CONSUMPTION, "net_consumption"),
device_class=SensorDeviceClass.CURRENT, # Production CT active_power is not used
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, (CtType.STORAGE, "battery_discharge"),
suggested_display_precision=3, )
entity_registry_enabled_default=False, ]
value_fn=attrgetter("current"), + [
on_phase=None, EnvoyCTSensorEntityDescription(
cttype=CtType.PRODUCTION, key=key,
), translation_key=(translation_key if translation_key != "" else key),
EnvoyCTSensorEntityDescription( native_unit_of_measurement=UnitOfFrequency.HERTZ,
key="production_ct_powerfactor", state_class=SensorStateClass.MEASUREMENT,
translation_key="production_ct_powerfactor", device_class=SensorDeviceClass.FREQUENCY,
device_class=SensorDeviceClass.POWER_FACTOR, suggested_display_precision=1,
state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False,
suggested_display_precision=2, value_fn=attrgetter("frequency"),
entity_registry_enabled_default=False, on_phase=None,
value_fn=attrgetter("power_factor"), cttype=cttype,
on_phase=None, )
cttype=CtType.PRODUCTION, for cttype, key, translation_key in (
), (CtType.NET_CONSUMPTION, "frequency", "net_ct_frequency"),
EnvoyCTSensorEntityDescription( (CtType.PRODUCTION, "production_ct_frequency", ""),
key="production_ct_metering_status", (CtType.STORAGE, "storage_ct_frequency", ""),
translation_key="production_ct_metering_status", )
device_class=SensorDeviceClass.ENUM, ]
options=list(CtMeterStatus), + [
entity_category=EntityCategory.DIAGNOSTIC, EnvoyCTSensorEntityDescription(
entity_registry_enabled_default=False, key=key,
value_fn=attrgetter("metering_status"), translation_key=(translation_key if translation_key != "" else key),
on_phase=None, native_unit_of_measurement=UnitOfElectricPotential.VOLT,
cttype=CtType.PRODUCTION, state_class=SensorStateClass.MEASUREMENT,
), device_class=SensorDeviceClass.VOLTAGE,
EnvoyCTSensorEntityDescription( suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
key="production_ct_status_flags", suggested_display_precision=1,
translation_key="production_ct_status_flags", entity_registry_enabled_default=False,
state_class=None, value_fn=attrgetter("voltage"),
entity_category=EntityCategory.DIAGNOSTIC, on_phase=None,
entity_registry_enabled_default=False, cttype=cttype,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags), )
on_phase=None, for cttype, key, translation_key in (
cttype=CtType.PRODUCTION, (CtType.NET_CONSUMPTION, "voltage", "net_ct_voltage"),
), (CtType.PRODUCTION, "production_ct_voltage", ""),
) (CtType.STORAGE, "storage_voltage", "storage_ct_voltage"),
)
CT_PRODUCTION_PHASE_SENSORS = { ]
(on_phase := PHASENAMES[phase]): [ + [
replace( EnvoyCTSensorEntityDescription(
sensor, key=key,
key=f"{sensor.key}_l{phase + 1}", translation_key=key,
translation_key=f"{sensor.translation_key}_phase", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT,
on_phase=on_phase, device_class=SensorDeviceClass.CURRENT,
translation_placeholders={"phase_name": f"l{phase + 1}"}, suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_current"),
(CtType.PRODUCTION, "production_ct_current"),
(CtType.STORAGE, "storage_ct_current"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=key,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=cttype,
)
for cttype, key in (
(CtType.NET_CONSUMPTION, "net_ct_powerfactor"),
(CtType.PRODUCTION, "production_ct_powerfactor"),
(CtType.STORAGE, "storage_ct_powerfactor"),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=list(CtMeterStatus),
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_metering_status",
"net_ct_metering_status",
),
(CtType.PRODUCTION, "production_ct_metering_status", ""),
(CtType.STORAGE, "storage_ct_metering_status", ""),
)
]
+ [
EnvoyCTSensorEntityDescription(
key=key,
translation_key=(translation_key if translation_key != "" else key),
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=cttype,
)
for cttype, key, translation_key in (
(
CtType.NET_CONSUMPTION,
"net_consumption_ct_status_flags",
"net_ct_status_flags",
),
(CtType.PRODUCTION, "production_ct_status_flags", ""),
(CtType.STORAGE, "storage_ct_status_flags", ""),
) )
for sensor in list(CT_PRODUCTION_SENSORS)
] ]
for phase in range(3)
}
CT_STORAGE_SENSORS = (
EnvoyCTSensorEntityDescription(
key="lifetime_battery_discharged",
translation_key="lifetime_battery_discharged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_delivered"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="lifetime_battery_charged",
translation_key="lifetime_battery_charged",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
suggested_unit_of_measurement=UnitOfEnergy.MEGA_WATT_HOUR,
suggested_display_precision=3,
value_fn=attrgetter("energy_received"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="battery_discharge",
translation_key="battery_discharge",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=3,
value_fn=attrgetter("active_power"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_frequency",
translation_key="storage_ct_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("frequency"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_voltage",
translation_key="storage_ct_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=attrgetter("voltage"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_current",
translation_key="storage_ct_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("current"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_powerfactor",
translation_key="storage_ct_powerfactor",
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=2,
entity_registry_enabled_default=False,
value_fn=attrgetter("power_factor"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_metering_status",
translation_key="storage_ct_metering_status",
device_class=SensorDeviceClass.ENUM,
options=list(CtMeterStatus),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=attrgetter("metering_status"),
on_phase=None,
cttype=CtType.STORAGE,
),
EnvoyCTSensorEntityDescription(
key="storage_ct_status_flags",
translation_key="storage_ct_status_flags",
state_class=None,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda ct: 0 if ct.status_flags is None else len(ct.status_flags),
on_phase=None,
cttype=CtType.STORAGE,
),
) )
CT_STORAGE_PHASE_SENSORS = { CT_PHASE_SENSORS = {
(on_phase := PHASENAMES[phase]): [ (on_phase := PHASENAMES[phase]): [
replace( replace(
sensor, sensor,
@@ -732,7 +591,7 @@ CT_STORAGE_PHASE_SENSORS = {
on_phase=on_phase, on_phase=on_phase,
translation_placeholders={"phase_name": f"l{phase + 1}"}, translation_placeholders={"phase_name": f"l{phase + 1}"},
) )
for sensor in list(CT_STORAGE_SENSORS) for sensor in list(CT_SENSORS)
] ]
for phase in range(3) for phase in range(3)
} }
@@ -1060,24 +919,14 @@ async def async_setup_entry(
if envoy_data.ctmeters: if envoy_data.ctmeters:
entities.extend( entities.extend(
EnvoyCTEntity(coordinator, description) EnvoyCTEntity(coordinator, description)
for sensors in ( for description in CT_SENSORS
CT_NET_CONSUMPTION_SENSORS,
CT_PRODUCTION_SENSORS,
CT_STORAGE_SENSORS,
)
for description in sensors
if description.cttype in envoy_data.ctmeters if description.cttype in envoy_data.ctmeters
) )
# Add Current Transformer phase entities # Add Current Transformer phase entities
if ctmeters_phases := envoy_data.ctmeters_phases: if ctmeters_phases := envoy_data.ctmeters_phases:
entities.extend( entities.extend(
EnvoyCTPhaseEntity(coordinator, description) EnvoyCTPhaseEntity(coordinator, description)
for sensors in ( for phase, descriptions in CT_PHASE_SENSORS.items()
CT_NET_CONSUMPTION_PHASE_SENSORS,
CT_PRODUCTION_PHASE_SENSORS,
CT_STORAGE_PHASE_SENSORS,
)
for phase, descriptions in sensors.items()
for description in descriptions for description in descriptions
if (cttype := description.cttype) in ctmeters_phases if (cttype := description.cttype) in ctmeters_phases
and phase in ctmeters_phases[cttype] and phase in ctmeters_phases[cttype]

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from urllib.parse import quote
import voluptuous as vol import voluptuous as vol
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
async def stream_source(self) -> str | None: async def stream_source(self) -> str | None:
"""Return the stream source.""" """Return the stream source."""
if self._rtsp_port: if self._rtsp_port:
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}" _username = quote(self._username)
_password = quote(self._password)
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
return None return None

View File

@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate", sidebar_title="climate",
sidebar_default_visible=False, sidebar_default_visible=False,
) )
async_register_built_in_panel(
hass,
"home",
sidebar_icon="mdi:home",
sidebar_title="home",
sidebar_default_visible=False,
)
async_register_built_in_panel(hass, "profile") async_register_built_in_panel(hass, "profile")
@@ -770,7 +777,9 @@ class ManifestJSONView(HomeAssistantView):
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
"type": "frontend/get_icons", "type": "frontend/get_icons",
vol.Required("category"): vol.In({"entity", "entity_component", "services"}), vol.Required("category"): vol.In(
{"entity", "entity_component", "services", "triggers", "conditions"}
),
vol.Optional("integration"): vol.All(cv.ensure_list, [str]), vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
} }
) )

View File

@@ -1,13 +1,14 @@
"""The Goodwe inverter component.""" """The Goodwe inverter component."""
from goodwe import InverterError, connect from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from goodwe.const import GOODWE_UDP_PORT
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from .config_flow import GoodweFlowHandler
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
@@ -15,28 +16,22 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
"""Set up the Goodwe components from a config entry.""" """Set up the Goodwe components from a config entry."""
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
model_family = entry.data[CONF_MODEL_FAMILY] model_family = entry.data[CONF_MODEL_FAMILY]
# Connect to Goodwe inverter # Connect to Goodwe inverter
try: try:
inverter = await connect( inverter = await connect(
host=host, host=host,
port=GOODWE_UDP_PORT, port=port,
family=model_family, family=model_family,
retries=10, retries=10,
) )
except InverterError as err_udp: except InverterError as err:
# First try with UDP failed, trying with the TCP port
try: try:
inverter = await connect( inverter = await async_check_port(hass, entry, host)
host=host,
port=GOODWE_TCP_PORT,
family=model_family,
retries=10,
)
except InverterError: except InverterError:
# Both ports are unavailable raise ConfigEntryNotReady from err
raise ConfigEntryNotReady from err_udp
device_info = DeviceInfo( device_info = DeviceInfo(
configuration_url="https://www.semsportal.com", configuration_url="https://www.semsportal.com",
@@ -66,6 +61,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
return True return True
async def async_check_port(
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
) -> Inverter:
"""Check the communication port of the inverter, it may have changed after a firmware update."""
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
family = type(inverter).__name__
hass.config_entries.async_update_entry(
entry,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: family,
},
)
return inverter
async def async_unload_entry( async def async_unload_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool: ) -> bool:
@@ -76,3 +88,31 @@ async def async_unload_entry(
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None: async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id) await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool:
"""Migrate old config entries."""
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
host = config_entry.data[CONF_HOST]
try:
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
host=host
)
except InverterError as err:
raise ConfigEntryNotReady from err
new_data = {
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__,
}
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
return True

View File

@@ -5,12 +5,12 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from goodwe import InverterError, connect from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST from homeassistant.const import CONF_HOST, CONF_PORT
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
@@ -26,9 +26,15 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow.""" """Handle a Goodwe config flow."""
VERSION = 1 MINOR_VERSION = 2
async def _handle_successful_connection(self, inverter, host): async def async_handle_successful_connection(
self,
inverter: Inverter,
host: str,
port: int,
) -> ConfigFlowResult:
"""Handle a successful connection storing it's values on the entry data."""
await self.async_set_unique_id(inverter.serial_number) await self.async_set_unique_id(inverter.serial_number)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
@@ -36,6 +42,7 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
title=DEFAULT_NAME, title=DEFAULT_NAME,
data={ data={
CONF_HOST: host, CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__, CONF_MODEL_FAMILY: type(inverter).__name__,
}, },
) )
@@ -48,19 +55,26 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
try: try:
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10) inverter, port = await self.async_detect_inverter_port(host=host)
except InverterError: except InverterError:
try: errors[CONF_HOST] = "connection_error"
inverter = await connect(
host=host, port=GOODWE_TCP_PORT, retries=10
)
except InverterError:
errors[CONF_HOST] = "connection_error"
else:
return await self._handle_successful_connection(inverter, host)
else: else:
return await self._handle_successful_connection(inverter, host) return await self.async_handle_successful_connection(
inverter, host, port
)
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
) )
@staticmethod
async def async_detect_inverter_port(
host: str,
) -> tuple[Inverter, int]:
"""Detects the port of the Inverter."""
port = GOODWE_UDP_PORT
try:
inverter = await connect(host=host, port=port, retries=10)
except InverterError:
port = GOODWE_TCP_PORT
inverter = await connect(host=host, port=port, retries=10)
return inverter, port

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google", "documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["googleapiclient"], "loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"] "requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
} }

View File

@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
# Due dates are returned always in UTC so we only need to # Due dates are returned always in UTC so we only need to
# parse the date portion which will be interpreted as a a local date. # parse the date portion which will be interpreted as a a local date.
due = datetime.fromisoformat(due_str).date() due = datetime.fromisoformat(due_str).date()
completed: datetime | None = None
if (completed_str := item.get("completed")) is not None:
completed = datetime.fromisoformat(completed_str)
return TodoItem( return TodoItem(
summary=item["title"], summary=item["title"],
uid=item["id"], uid=item["id"],
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
TodoItemStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION,
), ),
due=due, due=due,
completed=completed,
description=item.get("notes"), description=item.get("notes"),
) )

View File

@@ -0,0 +1,84 @@
"""The Google Weather integration."""
from __future__ import annotations
import asyncio
from google_weather_api import GoogleWeatherApi
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_REFERRER
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherRuntimeData,
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Set up Google Weather from a config entry."""
api = GoogleWeatherApi(
session=async_get_clientsession(hass),
api_key=entry.data[CONF_API_KEY],
referrer=entry.data.get(CONF_REFERRER),
language_code=hass.config.language,
)
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
for subentry in entry.subentries.values():
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
hass, entry, subentry, api
),
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
hass, entry, subentry, api
),
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
hass, entry, subentry, api
),
)
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
tasks = [
coro
for subentry_runtime_data in subentries_runtime_data.values()
for coro in (
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
]
await asyncio.gather(*tasks)
entry.runtime_data = GoogleWeatherRuntimeData(
api=api,
subentries_runtime_data=subentries_runtime_data,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,198 @@
"""Config flow for the Google Weather integration."""
from __future__ import annotations
import logging
from typing import Any
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(SECTION_API_KEY_OPTIONS): section(
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
),
}
)
async def _validate_input(
user_input: dict[str, Any],
api: GoogleWeatherApi,
errors: dict[str, str],
description_placeholders: dict[str, str],
) -> bool:
try:
await api.async_get_current_conditions(
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
errors["base"] = "cannot_connect"
description_placeholders["error_message"] = str(err)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return True
return False
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
"""Return the schema for a location with default values from the hass config."""
return vol.Schema(
{
vol.Required(CONF_NAME, default=hass.config.location_name): str,
vol.Required(
CONF_LOCATION,
default={
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
},
): LocationSelector(LocationSelectorConfig(radius=False)),
}
)
def _is_location_already_configured(
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
) -> bool:
"""Check if the location is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
# A more accurate way is to use the haversine formula, but for simplicity
# we use a simple distance check. The epsilon value is small anyway.
# This is mostly to capture cases where the user has slightly moved the location pin.
if (
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
<= epsilon
):
return True
return False
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Weather."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api = GoogleWeatherApi(
session=async_get_clientsession(self.hass),
api_key=api_key,
referrer=referrer,
language_code=self.hass.config.language,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title="Google Weather",
data={
CONF_API_KEY: api_key,
CONF_REFERRER: referrer,
},
subentries=[
{
"subentry_type": "location",
"data": user_input[CONF_LOCATION],
"title": user_input[CONF_NAME],
"unique_id": None,
},
],
)
else:
user_input = {}
schema = STEP_USER_DATA_SCHEMA.schema.copy()
schema.update(_get_location_schema(self.hass).schema)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"location": LocationSubentryFlowHandler}
class LocationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for location."""
async def async_step_location(
self,
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api: GoogleWeatherApi = self._get_entry().runtime_data.api
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
else:
user_input = {}
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
async_step_user = async_step_location

View File

@@ -0,0 +1,8 @@
"""Constants for the Google Weather integration."""
from typing import Final
DOMAIN = "google_weather"
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
CONF_REFERRER: Final = "referrer"

View File

@@ -0,0 +1,169 @@
"""The Google Weather coordinator."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TypeVar
from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
GoogleWeatherApi,
GoogleWeatherApiError,
HourlyForecastResponse,
)
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
"T",
bound=(
CurrentConditionsResponse
| DailyForecastResponse
| HourlyForecastResponse
| None
),
)
@dataclass
class GoogleWeatherSubEntryRuntimeData:
"""Runtime data for a Google Weather sub-entry."""
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
@dataclass
class GoogleWeatherRuntimeData:
"""Runtime data for the Google Weather integration."""
api: GoogleWeatherApi
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
"""Base class for Google Weather coordinators."""
config_entry: GoogleWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
data_type_name: str,
update_interval: timedelta,
api_method: Callable[..., Awaitable[T]],
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
update_interval=update_interval,
)
self.subentry = subentry
self._data_type_name = data_type_name
self._api_method = api_method
async def _async_update_data(self) -> T:
"""Fetch data from API and handle errors."""
try:
return await self._api_method(
self.subentry.data[CONF_LATITUDE],
self.subentry.data[CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
_LOGGER.error(
"Error fetching %s for %s: %s",
self._data_type_name,
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
class GoogleWeatherCurrentConditionsCoordinator(
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
):
"""Handle fetching current weather conditions."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"current weather conditions",
timedelta(minutes=15),
api.async_get_current_conditions,
)
class GoogleWeatherDailyForecastCoordinator(
GoogleWeatherBaseCoordinator[DailyForecastResponse]
):
"""Handle fetching daily weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"daily weather forecast",
timedelta(hours=1),
api.async_get_daily_forecast,
)
class GoogleWeatherHourlyForecastCoordinator(
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
):
"""Handle fetching hourly weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"hourly weather forecast",
timedelta(hours=1),
api.async_get_hourly_forecast,
)

View File

@@ -0,0 +1,28 @@
"""Base entity for Google Weather."""
from __future__ import annotations
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import GoogleWeatherConfigEntry
class GoogleWeatherBaseEntity(Entity):
"""Base entity for all Google Weather entities."""
_attr_has_entity_name = True
def __init__(
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "google_weather",
"name": "Google Weather",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_weather",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"requirements": ["python-google-weather-api==0.0.4"]
}

View File

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

View File

@@ -0,0 +1,65 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
"location": "Location coordinates",
"name": "Location name"
},
"description": "Get your API key from [here]({api_key_url}).",
"sections": {
"api_key_options": {
"data": {
"referrer": "HTTP referrer"
},
"data_description": {
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
},
"name": "Optional API key options"
}
}
}
}
},
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled."
},
"entry_type": "Location",
"error": {
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "Add location"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
}
}
}
}
}
}

View File

@@ -0,0 +1,366 @@
"""Weather entity."""
from __future__ import annotations
from google_weather_api import (
DailyForecastResponse,
HourlyForecastResponse,
WeatherCondition,
)
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_IS_DAYTIME,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_DEW_POINT,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
CoordinatorWeatherEntity,
Forecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
}
def _get_condition(
api_condition: WeatherCondition.Type, is_daytime: bool
) -> str | None:
"""Map Google Weather condition to Home Assistant condition."""
cond = _CONDITION_MAP[api_condition]
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
return ATTR_CONDITION_CLEAR_NIGHT
return cond
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
for subentry in entry.subentries.values():
async_add_entities(
[GoogleWeatherEntity(entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherEntity(
CoordinatorWeatherEntity[
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherDailyForecastCoordinator,
],
GoogleWeatherBaseEntity,
):
"""Representation of a Google Weather entity."""
_attr_attribution = "Data from Google Weather"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.MBAR
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
| WeatherEntityFeature.FORECAST_TWICE_DAILY
)
def __init__(
self,
entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the weather entity."""
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
super().__init__(
observation_coordinator=subentry_runtime_data.coordinator_observation,
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
)
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
@property
def condition(self) -> str | None:
"""Return the current condition."""
return _get_condition(
self.coordinator.data.weather_condition.type,
self.coordinator.data.is_daytime,
)
@property
def native_temperature(self) -> float:
"""Return the temperature."""
return self.coordinator.data.temperature.degrees
@property
def native_apparent_temperature(self) -> float:
"""Return the apparent temperature."""
return self.coordinator.data.feels_like_temperature.degrees
@property
def native_dew_point(self) -> float:
"""Return the dew point."""
return self.coordinator.data.dew_point.degrees
@property
def humidity(self) -> int:
"""Return the humidity."""
return self.coordinator.data.relative_humidity
@property
def uv_index(self) -> float:
"""Return the UV index."""
return float(self.coordinator.data.uv_index)
@property
def native_pressure(self) -> float:
"""Return the pressure."""
return self.coordinator.data.air_pressure.mean_sea_level_millibars
@property
def native_wind_gust_speed(self) -> float:
"""Return the wind gust speed."""
return self.coordinator.data.wind.gust.value
@property
def native_wind_speed(self) -> float:
"""Return the wind speed."""
return self.coordinator.data.wind.speed.value
@property
def wind_bearing(self) -> int:
"""Return the wind bearing."""
return self.coordinator.data.wind.direction.degrees
@property
def native_visibility(self) -> float:
"""Return the visibility."""
return self.coordinator.data.visibility.distance
@property
def cloud_coverage(self) -> float:
"""Return the Cloud coverage in %."""
return float(self.coordinator.data.cloud_cover)
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
coordinator = self.forecast_coordinators["daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.daytime_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
item.daytime_forecast.precipitation.probability.percent,
item.nighttime_forecast.precipitation.probability.percent,
),
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: (
item.daytime_forecast.precipitation.qpf.quantity
+ item.nighttime_forecast.precipitation.qpf.quantity
),
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
item.feels_like_max_temperature.degrees
),
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
item.daytime_forecast.wind.gust.value,
item.nighttime_forecast.wind.gust.value,
),
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
item.daytime_forecast.wind.speed.value,
item.nighttime_forecast.wind.speed.value,
),
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
}
for item in daily_data.forecast_days
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
coordinator = self.forecast_coordinators["hourly"]
assert coordinator
hourly_data = coordinator.data
assert isinstance(hourly_data, HourlyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.weather_condition.type, item.is_daytime
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
ATTR_FORECAST_UV_INDEX: item.uv_index,
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
}
for item in hourly_data.forecast_hours
]
@callback
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = self.forecast_coordinators["twice_daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
forecasts: list[Forecast] = []
for item in daily_data.forecast_days:
# Process daytime forecast
day_forecast = item.daytime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
day_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: True,
}
)
# Process nighttime forecast
night_forecast = item.nighttime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
night_forecast.weather_condition.type, is_daytime=False
),
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: False,
}
)
return forecasts

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any, cast from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import ( from aiohomeconnect.model import (
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
value=event.value, value=event.value,
) )
else: else:
event_value = event.value
if event_key in ( if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
): ) and isinstance(event_value, str):
await self.update_options( await self.update_options(
event_message_ha_id, event_message_ha_id,
event_key, event_key,
ProgramKey(cast(str, event.value)), ProgramKey(event_value),
) )
events[event_key] = event events[event_key] = event
self._call_event_listener(event_message) self._call_event_listener(event_message)

View File

@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
TooManyRequestsError, TooManyRequestsError,
) )
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
self.update_native_value() self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state() self.async_write_ha_state()
state = STATE_UNAVAILABLE if not available else self.state _LOGGER.debug("Updated %s", self)
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
@property @property
def bsh_key(self) -> str: def bsh_key(self) -> str:
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
as event updates should take precedence over the coordinator as event updates should take precedence over the coordinator
refresh. refresh.
""" """
return self._attr_available return self.appliance.info.connected and self._attr_available
class HomeConnectOptionEntity(HomeConnectEntity): class HomeConnectOptionEntity(HomeConnectEntity):

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aiohomeconnect"], "loggers": ["aiohomeconnect"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.0"], "requirements": ["aiohomeconnect==0.23.1"],
"zeroconf": ["_homeconnect._tcp.local."] "zeroconf": ["_homeconnect._tcp.local."]
} }

View File

@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Set the program value.""" """Set the program value."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key)) event = self.appliance.events.get(cast(EventKey, self.bsh_key))
self._attr_current_option = ( self._attr_current_option = (
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value)) PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
if event if event and isinstance(event_value := event.value, str)
else None else None
) )

View File

@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
self._update_native_value(status) self._update_native_value(status)
def _update_native_value(self, status: str | float) -> None: def _update_native_value(self, status: str | float | None) -> None:
"""Set the value of the sensor based on the given value.""" """Set the value of the sensor based on the given value."""
if status is None:
self._attr_native_value = None
return
match self.device_class: match self.device_class:
case SensorDeviceClass.TIMESTAMP: case SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dt_util.utcnow() + timedelta( self._attr_native_value = dt_util.utcnow() + timedelta(

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods.""" """Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800 ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"], "dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2", "documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"quality_scale": "bronze", "quality_scale": "bronze",
"usb": [ "usb": [
{ {

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -134,7 +134,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity.""" """Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR] BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType _picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED _zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is # Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type # installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality. # isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device) self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or ( firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type self._probed_firmware_info.firmware_type != expected_installed_firmware_type
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type, expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress( progress_callback=lambda offset, total: self.async_update_progress(
offset / total offset / total
), ),

View File

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

View File

@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions # Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = [] BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = ( _attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
device=self._current_device, device=self._current_device,
fw_data=fw_data, fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type, expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods, bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress, progress_callback=self._update_progress,
domain=self._config_entry.domain, domain=self._config_entry.domain,
) )

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info( async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> FirmwareInfo | None: ) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device.""" """Probe the running firmware on a SiLabs device."""
flasher = Flasher( flasher = Flasher(
device=device, device=device,
**( probe_methods=tuple(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]} (m.as_flasher_application_type(), baudrate)
if probe_methods for m, baudrate in application_probe_methods
else {} ),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
), ),
) )
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type( async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None: ) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device.""" """Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods) fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
)
if fw_info is None: if fw_info is None:
return None return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str, device: str,
fw_data: bytes, fw_data: bytes,
expected_installed_firmware_type: ApplicationType, expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (), bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
*, *,
domain: str = DOMAIN, domain: str = DOMAIN,
) -> FirmwareInfo: ) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.""" """Flash firmware to the SiLabs device."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
f" not in application probe methods {application_probe_methods!r}"
)
async with async_firmware_update_context(hass, device, domain): async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device) firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info) _LOGGER.debug("Identified firmware info: %s", firmware_info)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher( flasher = Flasher(
device=device, device=device,
probe_methods=( probe_methods=tuple(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), (m.as_flasher_application_type(), baudrate)
ApplicationType.EZSP.as_flasher_application_type(), for m, baudrate in application_probe_methods
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
), ),
bootloader_reset=tuple( bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods m.as_flasher_reset_target() for m in bootloader_reset_methods
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info( probed_firmware_info = await probe_silabs_firmware_info(
device, device,
probe_methods=(expected_installed_firmware_type,), bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
) )
if probed_firmware_info is None: if probed_firmware_info is None:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.usb import ( from homeassistant.components.usb import (
usb_service_info_from_device, usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
def _get_translation_placeholders(self) -> dict[str, str]: def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders.""" """Shared translation placeholders."""
placeholders = { placeholders = {

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"], "dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect", "documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"usb": [ "usb": [
{ {
"description": "*skyconnect v1.0*", "description": "*skyconnect v1.0*",

View File

@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import ( from .const import (
DOMAIN, DOMAIN,
FIRMWARE, FIRMWARE,
@@ -151,8 +152,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity.""" """SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
bootloader_reset_methods = [] APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods.""" """Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW] BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
async def async_step_install_zigbee_firmware( async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device) self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running # Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if ( if (

View File

@@ -7,5 +7,11 @@
"dependencies": ["hardware", "homeassistant_hardware"], "dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
"integration_type": "hardware", "integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"single_config_entry": true "single_config_entry": true
} }

View File

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
FirmwareInfo, FirmwareInfo,
ResetTarget,
) )
from homeassistant.components.update import UpdateDeviceClass from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity): class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity.""" """Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__( def __init__(
self, self,

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity.""" """Initialize AutomowerEntity."""
super().__init__(coordinator) super().__init__(coordinator)
self.mower_id = mower_id self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2) model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
"Husqvarna "
).removeprefix("HUSQVARNA ")
parts = model_witout_manufacturer.split(maxsplit=1)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)}, identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0], manufacturer="Husqvarna",
model=parts[1], model=parts[0].capitalize().removesuffix("®"),
model_id=parts[2], model_id=parts[1],
name=self.mower_attributes.system.name, name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number, serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden", suggested_area="Garden",

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.2"] "requirements": ["pylamarzocco==2.1.3"]
} }

View File

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware() await self.coordinator.device.update_firmware()
while ( while (
update_progress := await self.coordinator.device.get_firmware() update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateStatus.IN_PROGRESS: ).command_status is not UpdateStatus.UPDATED:
if counter >= MAX_UPDATE_WAIT: if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error() _raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar", "documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ical"], "loggers": ["ical"],
"requirements": ["ical==11.0.0"] "requirements": ["ical==11.1.0"]
} }

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo", "documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["ical==11.0.0"] "requirements": ["ical==11.1.0"]
} }

View File

@@ -154,6 +154,7 @@ class LocalTodoListEntity(TodoListEntity):
), ),
due=due, due=due,
description=item.description, description=item.description,
completed=item.completed,
) )
) )
self._attr_todo_items = todo_items self._attr_todo_items = todo_items

View File

@@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.5.3"] "requirements": ["lunatone-rest-api-client==0.5.7"]
} }

View File

@@ -194,7 +194,7 @@
"name": "Altitude above sea level" "name": "Altitude above sea level"
}, },
"auto_relock_timer": { "auto_relock_timer": {
"name": "Autorelock time" "name": "Auto-relock time"
}, },
"cook_time": { "cook_time": {
"name": "Cooking time" "name": "Cooking time"

View File

@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
forecasts_by_date[date].append(timestamp) forecasts_by_date[date].append(timestamp)
daily_forecasts = [] daily_forecasts = []
for date in sorted(forecasts_by_date.keys())[:5]: for date in sorted(forecasts_by_date.keys()):
day_forecasts = forecasts_by_date[date] day_forecasts = forecasts_by_date[date]
if not day_forecasts: if not day_forecasts:
continue continue
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
return None return None
return [ return [
self._convert_forecast_data(forecast_data) self._convert_forecast_data(forecast_data)
for forecast_data in self.coordinator.data.forecast_timestamps[:24] for forecast_data in self.coordinator.data.forecast_timestamps
] ]

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Final from typing import Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
self._device_id, self._device_id,
{PROCESS_ACTION: self.entity_description.press_data}, {PROCESS_ACTION: self.entity_description.press_data},
) )
except aiohttp.ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aiohttp from aiohttp import ClientResponseError
from pymiele import MieleDevice, MieleTemperature from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
cast(float, kwargs.get(ATTR_TEMPERATURE)), cast(float, kwargs.get(ATTR_TEMPERATURE)),
self.entity_description.zone, self.entity_description.zone,
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
_LOGGER.debug( _LOGGER.debug(
"Error fetching actions for device %s: Status: %s, Message: %s", "Error fetching actions for device %s: Status: %s, Message: %s",
device_id, device_id,
err.status, str(err.status),
err.message, err.message,
) )
actions_json = {} actions_json = {}

View File

@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {VENTILATION_STEP: ventilation_step} self._device_id, {VENTILATION_STEP: ventilation_step}
) )
except ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err
self.device.state_ventilation_step = ventilation_step self.device.state_ventilation_step = ventilation_step
self.async_write_ha_state() self.async_write_ha_state()
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
"err_status": str(ex.status),
}, },
) from ex ) from ex

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final from typing import Any, Final
import aiohttp from aiohttp import ClientResponseError
from homeassistant.components.light import ( from homeassistant.components.light import (
ColorMode, ColorMode,
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
await self.api.send_action( await self.api.send_action(
self._device_id, {self.entity_description.light_type: mode} self._device_id, {self.entity_description.light_type: mode}
) )
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -4,7 +4,7 @@ from datetime import timedelta
import logging import logging
from typing import cast from typing import cast
import aiohttp from aiohttp import ClientResponseError
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
data = {"programId": call.data[ATTR_PROGRAM_ID]} data = {"programId": call.data[ATTR_PROGRAM_ID]}
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_error", translation_key="set_program_error",
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
data["temperature"] = call.data[ATTR_TEMPERATURE] data["temperature"] = call.data[ATTR_TEMPERATURE]
try: try:
await api.set_program(serial_number, data) await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_program_oven_error", translation_key="set_program_oven_error",
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
try: try:
programs = await api.get_programs(serial_number) programs = await api.get_programs(serial_number)
except aiohttp.ClientResponseError as ex: except ClientResponseError as ex:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="get_programs_error", translation_key="get_programs_error",

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging import logging
from typing import Any, Final, cast from typing import Any, Final, cast
import aiohttp from aiohttp import ClientResponseError
from pymiele import MieleDevice from pymiele import MieleDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
"""Set switch to mode.""" """Set switch to mode."""
try: try:
await self.api.send_action(self._device_id, mode) await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
"""Set switch to mode.""" """Set switch to mode."""
try: try:
await self.api.send_action(self._device_id, mode) await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err: except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",

View File

@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
"""Send action to the device.""" """Send action to the device."""
try: try:
await self.api.send_action(device_id, action) await self.api.send_action(device_id, action)
except ClientResponseError as ex: except ClientResponseError as err:
_LOGGER.debug("Error setting vacuum state for %s: %s", self.entity_id, err)
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="set_state_error", translation_key="set_state_error",
translation_placeholders={ translation_placeholders={
"entity": self.entity_id, "entity": self.entity_id,
}, },
) from ex ) from err
async def async_clean_spot(self, **kwargs: Any) -> None: async def async_clean_spot(self, **kwargs: Any) -> None:
"""Clean spot.""" """Clean spot."""

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill", "documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["mill", "mill_local"], "loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"] "requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
} }

View File

@@ -61,10 +61,12 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)]) async_add_entities([MobileAppBinarySensor(data, config_entry)])
async_dispatcher_connect( config_entry.async_on_unload(
hass, async_dispatcher_connect(
f"{DOMAIN}_{ENTITY_TYPE}_register", hass,
handle_sensor_registration, f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
) )

View File

@@ -72,10 +72,12 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)]) async_add_entities([MobileAppSensor(data, config_entry)])
async_dispatcher_connect( config_entry.async_on_unload(
hass, async_dispatcher_connect(
f"{DOMAIN}_{ENTITY_TYPE}_register", hass,
handle_sensor_registration, f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
) )

View File

@@ -88,6 +88,8 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
if self._precision > 0 or self._scale != int(self._scale): if self._precision > 0 or self._scale != int(self._scale):
self._value_is_int = False self._value_is_int = False
if self._precision > 0 and self._data_type not in ["string", "custom"]:
self._attr_suggested_display_precision = self._precision
async def async_setup_slaves( async def async_setup_slaves(
self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any]

View File

@@ -1312,6 +1312,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]", "speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mvglive", "documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["MVG"], "loggers": ["MVG"],
"quality_scale": "legacy",
"requirements": ["mvg==1.4.0"] "requirements": ["mvg==1.4.0"]
} }

View File

@@ -109,7 +109,7 @@
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
}, },
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}, },
"update_failed": { "update_failed": {
"message": "Failed to update drive state" "message": "Failed to update drive state"

View File

@@ -9,5 +9,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"] "requirements": ["openai==2.8.0", "python-open-router==0.3.3"]
} }

View File

@@ -338,6 +338,13 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options.pop(CONF_CODE_INTERPRETER) options.pop(CONF_CODE_INTERPRETER)
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"): if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
if model.startswith("gpt-5.1"):
reasoning_options = ["none", "low", "medium", "high"]
elif model.startswith("gpt-5"):
reasoning_options = ["minimal", "low", "medium", "high"]
else:
reasoning_options = ["low", "medium", "high"]
step_schema.update( step_schema.update(
{ {
vol.Optional( vol.Optional(
@@ -345,9 +352,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
default=RECOMMENDED_REASONING_EFFORT, default=RECOMMENDED_REASONING_EFFORT,
): SelectSelector( ): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=["low", "medium", "high"] options=reasoning_options,
if model.startswith("o")
else ["minimal", "low", "medium", "high"],
translation_key=CONF_REASONING_EFFORT, translation_key=CONF_REASONING_EFFORT,
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
) )

View File

@@ -510,6 +510,9 @@ class OpenAIBaseLLMEntity(Entity):
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY) "verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
} }
if model_args["model"].startswith("gpt-5.1"):
model_args["prompt_cache_retention"] = "24h"
tools: list[ToolParam] = [] tools: list[ToolParam] = []
if chat_log.llm_api: if chat_log.llm_api:
tools = [ tools = [

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation", "documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["openai==2.2.0"] "requirements": ["openai==2.8.0"]
} }

View File

@@ -146,7 +146,8 @@
"high": "[%key:common::state::high%]", "high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]", "low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]", "medium": "[%key:common::state::medium%]",
"minimal": "Minimal" "minimal": "Minimal",
"none": "None"
} }
}, },
"search_context_size": { "search_context_size": {

View File

@@ -36,7 +36,7 @@
}, },
"exceptions": { "exceptions": {
"oauth2_implementation_unavailable": { "oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry" "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
} }
} }
} }

View File

@@ -2,5 +2,17 @@
from __future__ import annotations from __future__ import annotations
from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate
DOMAIN = "pooldose" DOMAIN = "pooldose"
MANUFACTURER = "SEKO" MANUFACTURER = "SEKO"
# Mapping of device units to Home Assistant units
UNIT_MAPPING: dict[str, str] = {
# Temperature units
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
# Volume flow rate units
"m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
"L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND,
}

View File

@@ -1,6 +1,15 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"cl": {
"default": "mdi:pool"
},
"cl_type_dosing": {
"default": "mdi:flask"
},
"flow_rate": {
"default": "mdi:pipe-valve"
},
"ofa_orp_time": { "ofa_orp_time": {
"default": "mdi:clock" "default": "mdi:clock"
}, },
@@ -22,6 +31,9 @@
"orp_type_dosing": { "orp_type_dosing": {
"default": "mdi:flask" "default": "mdi:flask"
}, },
"peristaltic_cl_dosing": {
"default": "mdi:pump"
},
"peristaltic_orp_dosing": { "peristaltic_orp_dosing": {
"default": "mdi:pump" "default": "mdi:pump"
}, },

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -10,36 +11,61 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
EntityCategory,
UnitOfElectricPotential,
UnitOfTime,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PooldoseConfigEntry from . import PooldoseConfigEntry
from .const import UNIT_MAPPING
from .entity import PooldoseEntity from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription( @dataclass(frozen=True, kw_only=True)
class PooldoseSensorEntityDescription(SensorEntityDescription):
"""Describes PoolDose sensor entity."""
use_dynamic_unit: bool = False
SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = (
PooldoseSensorEntityDescription(
key="temperature", key="temperature",
device_class=SensorDeviceClass.TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE,
# Unit dynamically determined via API use_dynamic_unit=True,
), ),
SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH), PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="orp", key="orp",
translation_key="orp", translation_key="orp",
device_class=SensorDeviceClass.VOLTAGE, device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="cl",
translation_key="cl",
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
PooldoseSensorEntityDescription(
key="flow_rate",
translation_key="flow_rate",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
use_dynamic_unit=True,
),
PooldoseSensorEntityDescription(
key="ph_type_dosing", key="ph_type_dosing",
translation_key="ph_type_dosing", translation_key="ph_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["alcalyne", "acid"], options=["alcalyne", "acid"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="peristaltic_ph_dosing", key="peristaltic_ph_dosing",
translation_key="peristaltic_ph_dosing", translation_key="peristaltic_ph_dosing",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -47,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["proportional", "on_off", "timed"], options=["proportional", "on_off", "timed"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="ofa_ph_time", key="ofa_ph_time",
translation_key="ofa_ph_time", translation_key="ofa_ph_time",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -55,7 +81,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES, native_unit_of_measurement=UnitOfTime.MINUTES,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="orp_type_dosing", key="orp_type_dosing",
translation_key="orp_type_dosing", translation_key="orp_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -63,7 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["low", "high"], options=["low", "high"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="peristaltic_orp_dosing", key="peristaltic_orp_dosing",
translation_key="peristaltic_orp_dosing", translation_key="peristaltic_orp_dosing",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -71,7 +97,23 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"], options=["off", "proportional", "on_off", "timed"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="cl_type_dosing",
translation_key="cl_type_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["low", "high"],
),
PooldoseSensorEntityDescription(
key="peristaltic_cl_dosing",
translation_key="peristaltic_cl_dosing",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
options=["off", "proportional", "on_off", "timed"],
),
PooldoseSensorEntityDescription(
key="ofa_orp_time", key="ofa_orp_time",
translation_key="ofa_orp_time", translation_key="ofa_orp_time",
device_class=SensorDeviceClass.DURATION, device_class=SensorDeviceClass.DURATION,
@@ -79,7 +121,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfTime.MINUTES, native_unit_of_measurement=UnitOfTime.MINUTES,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="ph_calibration_type", key="ph_calibration_type",
translation_key="ph_calibration_type", translation_key="ph_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -87,7 +129,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point", "2_points"], options=["off", "reference", "1_point", "2_points"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="ph_calibration_offset", key="ph_calibration_offset",
translation_key="ph_calibration_offset", translation_key="ph_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -96,7 +138,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="ph_calibration_slope", key="ph_calibration_slope",
translation_key="ph_calibration_slope", translation_key="ph_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -105,7 +147,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="orp_calibration_type", key="orp_calibration_type",
translation_key="orp_calibration_type", translation_key="orp_calibration_type",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -113,7 +155,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM, device_class=SensorDeviceClass.ENUM,
options=["off", "reference", "1_point"], options=["off", "reference", "1_point"],
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="orp_calibration_offset", key="orp_calibration_offset",
translation_key="orp_calibration_offset", translation_key="orp_calibration_offset",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -122,7 +164,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
), ),
SensorEntityDescription( PooldoseSensorEntityDescription(
key="orp_calibration_slope", key="orp_calibration_slope",
translation_key="orp_calibration_slope", translation_key="orp_calibration_slope",
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
@@ -163,6 +205,8 @@ async def async_setup_entry(
class PooldoseSensor(PooldoseEntity, SensorEntity): class PooldoseSensor(PooldoseEntity, SensorEntity):
"""Sensor entity for the Seko PoolDose Python API.""" """Sensor entity for the Seko PoolDose Python API."""
entity_description: PooldoseSensorEntityDescription
@property @property
def native_value(self) -> float | int | str | None: def native_value(self) -> float | int | str | None:
"""Return the current value of the sensor.""" """Return the current value of the sensor."""
@@ -175,9 +219,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity):
def native_unit_of_measurement(self) -> str | None: def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement.""" """Return the unit of measurement."""
if ( if (
self.entity_description.key == "temperature" self.entity_description.use_dynamic_unit
and (data := self.get_data()) is not None and (data := self.get_data()) is not None
and (device_unit := data.get("unit"))
): ):
return data["unit"] # °C or °F # Map device unit to Home Assistant unit, return None if unknown
return UNIT_MAPPING.get(device_unit)
# Fall back to static unit from entity description
return super().native_unit_of_measurement return super().native_unit_of_measurement

View File

@@ -34,6 +34,19 @@
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"cl": {
"name": "Chlorine"
},
"cl_type_dosing": {
"name": "Chlorine dosing type",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]"
}
},
"flow_rate": {
"name": "Flow rate"
},
"ofa_orp_time": { "ofa_orp_time": {
"name": "ORP overfeed alert time" "name": "ORP overfeed alert time"
}, },
@@ -64,6 +77,15 @@
"low": "[%key:common::state::low%]" "low": "[%key:common::state::low%]"
} }
}, },
"peristaltic_cl_dosing": {
"name": "Chlorine peristaltic dosing",
"state": {
"off": "[%key:common::state::off%]",
"on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]",
"proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]",
"timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]"
}
},
"peristaltic_orp_dosing": { "peristaltic_orp_dosing": {
"name": "ORP peristaltic dosing", "name": "ORP peristaltic dosing",
"state": { "state": {

View File

@@ -128,6 +128,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]", "speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]", "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from ..const import SupportedDialect from ..const import SupportedDialect
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
from ..util import session_scope from ..util import session_scope
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
or dialect_kwargs.get("mariadb_collate") or dialect_kwargs.get("mariadb_collate")
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001 or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
) )
if collate and collate != "utf8mb4_unicode_ci": if collate and collate != MYSQL_COLLATE:
_LOGGER.debug( _LOGGER.debug(
"Database %s collation is not utf8mb4_unicode_ci", "Database %s collation is not %s",
table, table,
MYSQL_COLLATE,
) )
schema_errors.add(f"{table}.utf8mb4_unicode_ci") schema_errors.add(f"{table}.{MYSQL_COLLATE}")
return schema_errors return schema_errors
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
table_name = table_object.__tablename__ table_name = table_object.__tablename__
if ( if (
f"{table_name}.4-byte UTF-8" in schema_errors f"{table_name}.4-byte UTF-8" in schema_errors
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
): ):
from ..migration import ( # noqa: PLC0415 from ..migration import ( # noqa: PLC0415
_correct_table_character_set_and_collation, _correct_table_character_set_and_collation,

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration.""" """Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52 SCHEMA_VERSION = 53
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36 LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16 CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci" MYSQL_COLLATE = "utf8mb4_bin"
MYSQL_DEFAULT_CHARSET = "utf8mb4" MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB" MYSQL_ENGINE = "InnoDB"

View File

@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21): class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
def _apply_update(self) -> None: def _apply_update(self) -> None:
"""Version specific update method.""" """Version specific update method."""
# Try to change the character set of the statistic_meta table # Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL: if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in ("events", "states", "statistics_meta"): for table in ("events", "states", "statistics_meta"):
_correct_table_character_set_and_collation(table, self.session_maker) _correct_table_character_set_and_collation(table, self.session_maker)
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
) )
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in (
"events",
"event_data",
"states",
"state_attributes",
"statistics",
"statistics_meta",
"statistics_short_term",
):
_correct_table_character_set_and_collation(table, self.session_maker)
def _migrate_statistics_columns_to_timestamp_removing_duplicates( def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant, hass: HomeAssistant,
instance: Recorder, instance: Recorder,
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
"""Correct issues detected by validate_db_schema.""" """Correct issues detected by validate_db_schema."""
# Attempt to convert the table to utf8mb4 # Attempt to convert the table to utf8mb4
_LOGGER.warning( _LOGGER.warning(
"Updating character set and collation of table %s to utf8mb4. %s", "Updating table %s to character set %s and collation %s. %s",
table, table,
MYSQL_DEFAULT_CHARSET,
MYSQL_COLLATE,
MIGRATION_NOTE_MINUTES, MIGRATION_NOTE_MINUTES,
) )
with ( with (

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["ical"], "loggers": ["ical"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["ical==11.0.0"] "requirements": ["ical==11.1.0"]
} }

View File

@@ -58,7 +58,8 @@ PLATFORMS = [
Platform.SWITCH, Platform.SWITCH,
Platform.UPDATE, Platform.UPDATE,
] ]
DEVICE_UPDATE_INTERVAL = timedelta(seconds=60) DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24) FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
NUM_CRED_ERRORS = 3 NUM_CRED_ERRORS = 3
@@ -137,9 +138,12 @@ async def async_setup_entry(
} }
hass.config_entries.async_update_entry(config_entry, data=data) hass.config_entries.async_update_entry(config_entry, data=data)
min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2)
update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)
async def async_device_config_update() -> None: async def async_device_config_update() -> None:
"""Update the host state cache and renew the ONVIF-subscription.""" """Update the host state cache and renew the ONVIF-subscription."""
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): async with asyncio.timeout(update_timeout):
try: try:
await host.update_states() await host.update_states()
except CredentialsInvalidError as err: except CredentialsInvalidError as err:
@@ -156,7 +160,7 @@ async def async_setup_entry(
host.credential_errors = 0 host.credential_errors = 0
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): async with asyncio.timeout(min_timeout):
await host.renew() await host.renew()
if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED: if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
@@ -171,7 +175,7 @@ async def async_setup_entry(
async def async_check_firmware_update() -> None: async def async_check_firmware_update() -> None:
"""Check for firmware updates.""" """Check for firmware updates."""
async with asyncio.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): async with asyncio.timeout(min_timeout):
try: try:
await host.api.check_new_firmware(host.firmware_ch_list) await host.api.check_new_firmware(host.firmware_ch_list)
except ReolinkError as err: except ReolinkError as err:
@@ -197,7 +201,10 @@ async def async_setup_entry(
config_entry=config_entry, config_entry=config_entry,
name=f"reolink.{host.api.nvr_name}", name=f"reolink.{host.api.nvr_name}",
update_method=async_device_config_update, update_method=async_device_config_update,
update_interval=DEVICE_UPDATE_INTERVAL, update_interval=max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
),
) )
firmware_coordinator = DataUpdateCoordinator( firmware_coordinator = DataUpdateCoordinator(
hass, hass,

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.4"] "requirements": ["reolink-aio==0.16.5"]
} }

View File

@@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator, DataUpdateCoordinator,
) )
from . import DEVICE_UPDATE_INTERVAL from . import DEVICE_UPDATE_INTERVAL_MIN, DEVICE_UPDATE_INTERVAL_PER_CAM
from .const import DOMAIN from .const import DOMAIN
from .entity import ( from .entity import (
ReolinkChannelCoordinatorEntity, ReolinkChannelCoordinatorEntity,
@@ -221,7 +221,10 @@ class ReolinkUpdateBaseEntity(
async def _resume_update_coordinator(self, *args: Any) -> None: async def _resume_update_coordinator(self, *args: Any) -> None:
"""Resume updating the states using the data update coordinator (after reboots).""" """Resume updating the states using the data update coordinator (after reboots)."""
self._reolink_data.device_coordinator.update_interval = DEVICE_UPDATE_INTERVAL self._reolink_data.device_coordinator.update_interval = max(
DEVICE_UPDATE_INTERVAL_MIN,
DEVICE_UPDATE_INTERVAL_PER_CAM * self._host.api.num_cameras,
)
try: try:
await self._reolink_data.device_coordinator.async_refresh() await self._reolink_data.device_coordinator.async_refresh()
finally: finally:

View File

@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ruuvitag BLE device from a config entry.""" """Set up Ruuvi BLE device from a config entry."""
address = entry.unique_id address = entry.unique_id
assert address is not None assert address is not None
data = RuuvitagBluetoothDeviceData() data = RuuvitagBluetoothDeviceData()

View File

@@ -1,6 +1,6 @@
{ {
"domain": "ruuvitag_ble", "domain": "ruuvitag_ble",
"name": "RuuviTag BLE", "name": "Ruuvi BLE",
"bluetooth": [ "bluetooth": [
{ {
"connectable": false, "connectable": false,

View File

@@ -191,7 +191,7 @@ async def async_setup_entry(
entry: config_entries.ConfigEntry, entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Ruuvitag BLE sensors.""" """Set up the Ruuvi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id entry.entry_id
] ]
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
], ],
SensorEntity, SensorEntity,
): ):
"""Representation of a Ruuvitag BLE sensor.""" """Representation of a Ruuvi BLE sensor."""
@property @property
def native_value(self) -> int | float | None: def native_value(self) -> int | float | None:

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