Compare commits

...

153 Commits

Author SHA1 Message Date
abmantis
c2229bfbdf Add translated reasons to Govee Light Local setup failures 2026-02-19 22:11:18 +00:00
Patrick Vorgers
0996ad4d1d Add pagination support for IDrive e2 (#162960) 2026-02-19 22:42:04 +01:00
wollew
e8885de8c2 add number platform to Velux integration for ExteriorHeating nodes (#162857) 2026-02-19 19:58:13 +01:00
J. Nick Koston
03d9c2cf7b Add Trane Local integration (#163301) 2026-02-19 12:39:58 -06:00
epenet
7f3583587d Combine matter snapshot tests (#162695)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-19 19:38:33 +01:00
Brett Adams
e009440bf9 Mark action-setup quality scale rule as done for Advantage Air (#163208)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 19:25:41 +01:00
Noah Husby
43dccf15ba Add room correction intensity to Cambridge Audio (#163306) 2026-02-19 19:25:14 +01:00
Josef Zweck
c647ab1877 Add proper ImplementationUnvailable handling to onedrive for business (#163258)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-19 19:24:31 +01:00
JannisPohle
6b395b2703 Add test for device_class inheritance in the min/max integration (#161123) 2026-02-19 19:18:41 +01:00
Thomas Sejr Madsen
882a44a1c2 Fix touchline_sl zone availability when alarm state is set (#163338) 2026-02-19 19:13:44 +01:00
Christopher Fenner
3c9a505fc3 Handle gateway issues during setup in EnOcean integration (#163168)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-19 17:53:29 +00:00
Sab44
b2679ddc42 Update json fixture to reflect response from current LHM versions (#163248) 2026-02-19 18:15:16 +01:00
Andrew Jackson
2055082993 Handle Mastodon auth fail in coordinator (#163234) 2026-02-19 18:14:14 +01:00
Andreas Jakl
6f49f9a12a NRGkick: do not update vehicle connected timestamp when vehicle is not connected (#163292) 2026-02-19 18:08:50 +01:00
Petar Petrov
36c560b7bf Add flow rate (stat_rate) tracking for gas and water (#163274) 2026-02-19 18:08:16 +01:00
hanwg
05abe7efe0 Add callback inline keyboard tests for Telegram bot (#163328) 2026-02-19 17:50:51 +01:00
Manu
865ec96429 Add notify platform to HTML5 integration (#163229) 2026-02-19 17:50:04 +01:00
epenet
e6dbed0a87 Use shorthand attributes in geonetnz_quakes (#163568)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 17:46:37 +01:00
A. Gideonse
a3fd2f692e Add switch platform to Indevolt integration (#163522)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 17:46:13 +01:00
konsulten
eb7e00346d Fixing minor case errors in strings for systemnexa2 (#163567) 2026-02-19 17:39:00 +01:00
Manu
77159e612e Improve error handling in Uptime Kuma (#163477) 2026-02-19 17:23:10 +01:00
mettolen
05f9e25f29 Pump pyliebherrhomeapi to 0.3.0 (#163450) 2026-02-19 17:10:10 +01:00
Denis Shulyaka
7fa51117a9 Update Anthropic repair flow (#163303) 2026-02-19 17:09:09 +01:00
epenet
9e87fa75f8 Mark entity capability/state attribute type hints as mandatory (#163300)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-19 17:02:38 +01:00
epenet
0188f2ffec Mark is_on property as mandatory in binary sensors and toggle entities (#163556) 2026-02-19 17:01:50 +01:00
epenet
c144aec03e Use shorthand attributes in opple light (#163519) 2026-02-19 15:50:15 +01:00
epenet
1cb44aef64 Use shorthand attributes in pilight (#163542) 2026-02-19 15:50:00 +01:00
epenet
900f2300ad Use shorthand attributes in eufy light (#163521) 2026-02-19 15:49:48 +01:00
epenet
b075fba594 Use shorthand attributes in greenwave light (#163526) 2026-02-19 15:49:33 +01:00
epenet
c2ba97fb79 Use shorthand attributes in futurenow light (#163523) 2026-02-19 15:49:16 +01:00
epenet
d0a373aecc Use shorthand attributes in lw12wifi light (#163532) 2026-02-19 15:48:56 +01:00
epenet
758225edad Use shorthand attributes in scsgate light (#163537) 2026-02-19 15:48:43 +01:00
epenet
8ab1a527a4 Use shorthand attributes in rflink (#163555) 2026-02-19 15:48:05 +01:00
epenet
c7582b2f25 Use shorthand attributes in mystrom binary sensor (#163518) 2026-02-19 15:29:39 +01:00
epenet
91b8a67ce2 Use shorthand attributes in scsgate switch (#163510) 2026-02-19 15:23:20 +01:00
epenet
2b13ff98da Use shorthand attributes in itach remote (#163516) 2026-02-19 15:07:50 +01:00
epenet
fd2d9c2ee2 Use shorthand attributes in raincloud (#163515) 2026-02-19 14:56:52 +01:00
Manu
61b5466dcc Add state_class to sensors in Uptime Kuma (#163495) 2026-02-19 14:54:29 +01:00
epenet
bc4af64bea Use shorthand attributes in pencom switch (#163509) 2026-02-19 14:54:00 +01:00
epenet
3323f84c22 Use shorthand attributes in hikvisioncam switch (#163504) 2026-02-19 14:47:10 +01:00
epenet
b1f48a5886 Use shorthand attributes in kankun switch (#163505) 2026-02-19 14:46:55 +01:00
epenet
a14b1db886 Use shorthand attribute in eufy switch (#163503) 2026-02-19 14:46:22 +01:00
epenet
9de89b923e Use shorthand attributes in orvibo switch (#163508) 2026-02-19 14:46:07 +01:00
epenet
21cf5dc321 Use shorthand attribute in elv switch (#163488) 2026-02-19 14:30:27 +01:00
epenet
fe32582233 Use shorthand attribute in edimax switch (#163487) 2026-02-19 14:30:10 +01:00
epenet
6ebf19c4ba Use shorthand attribute in danfoss_air switch (#163486) 2026-02-19 14:29:39 +01:00
Willem-Jan van Rootselaar
5794189f8d Add strict typing for BSB-Lan integration (#163236) 2026-02-19 14:19:10 +01:00
A. Gideonse
c336e58afc Add numbers platform to Indevolt integration (#163298)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-19 13:55:50 +01:00
Manu
cdad602af0 Add new sensor to Uptime Kuma (#163468) 2026-02-19 13:53:04 +01:00
Stefan Agner
520046cd82 Ignore WAKEUP_CHANNEL addition in Thread dataset with same timestamp (#163440)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 13:42:37 +01:00
Willem-Jan van Rootselaar
e0b2ff0b2a Bump python-bsblan version to 4.2.1 (#163439) 2026-02-19 13:41:31 +01:00
epenet
6164198bde Use shorthand attributes in versasense switch (#163442) 2026-02-19 13:40:41 +01:00
epenet
dd41b4cefd Use shorthand attribute in tellstick toggle entities (#163443) 2026-02-19 13:40:09 +01:00
epenet
ccb8d6af44 Use shorthand attribute in x10 light (#163444) 2026-02-19 13:39:55 +01:00
epenet
6e8c064474 Improve type hints in tesla_wall_connector binary sensor (#163445) 2026-02-19 13:39:35 +01:00
epenet
7079eda8d9 Improve type hints in philips_js light (#163448) 2026-02-19 13:39:20 +01:00
Brett Adams
4e3832758b Add charge cable and charge port latch sensors to Tessie (#163207)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 13:38:55 +01:00
Brett Adams
773c3c4f07 Add diagnostics support to Splunk integration (#163453) 2026-02-19 13:38:17 +01:00
konsulten
b73beba152 System Nexa 2 Core Integration (#159140)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-19 13:31:17 +01:00
epenet
82589b613d Fix pytest warnings in screenlogic (#163455) 2026-02-19 12:57:55 +01:00
J. Diego Rodríguez Royo
c9b5f5f2c1 Use a coordinator per appliance in Home Connect (#152518)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 12:35:19 +01:00
Erwin Douna
725b45db7f Add config URL to Proxmox (#163414)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 12:31:44 +01:00
Pierre PÉRONNET
b194741a13 Add custom headers support to downloader (#160541)
Signed-off-by: Pierre PÉRONNET <pierre.peronnet@gmail.com>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-02-19 12:29:30 +01:00
epenet
4615b4d104 Add return type hint to is_on property (#163441) 2026-02-19 11:24:38 +01:00
A. Gideonse
2c7d9cb62e Bump indevolt-api requirement to 1.2.3 (#163429) 2026-02-19 11:22:50 +01:00
AlCalzone
e229ba591a Use opening/closing state for Z-Wave covers (#163368)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-19 10:41:52 +01:00
Rob Bierbooms
7914ebe54e Add config flow to InfluxDB integration (#134463)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-02-19 10:33:32 +01:00
Andreas Jakl
3abaa99706 Add charge control to NRGkick integration (new number platform) (#163273)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-19 10:31:09 +01:00
karwosts
86d7fdfe1e Allow history_stats to configure state_class: total_increasing (#148637) 2026-02-19 10:16:47 +01:00
epenet
676c42d578 Refactor write_ha_state logic in Tuya (#163431) 2026-02-19 10:13:54 +01:00
Manu
39909b7493 Bump pythonkuma to 0.5.0 (#163430) 2026-02-19 09:57:31 +01:00
Manu
6aef9a99e6 Deprecate action call without config entry in DuckDNS integration (#163269) 2026-02-19 08:43:46 +01:00
Joost Lekkerkerker
ff036f38a0 Add integration_type hub to sharkiq (#163392) 2026-02-19 08:31:40 +01:00
On Freund
53e3b4caf0 Bump py-nymta to 0.4.0 (#163418) 2026-02-19 08:30:49 +01:00
Kamil Breguła
dbdc030b74 Enable strict typing for 10 components (#163420)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-19 08:30:24 +01:00
Kamil Breguła
ee0b24f808 Add sensor showing total size of AWS S3 backups (#162513)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-19 08:29:40 +01:00
Joost Lekkerkerker
c0fd8ff342 Add integration_type hub to smappee (#163397) 2026-02-19 08:15:25 +01:00
Joost Lekkerkerker
84d2ec484d Add integration_type device to slimproto (#163396) 2026-02-19 08:14:47 +01:00
Joost Lekkerkerker
844b20e2fc Add integration_type hub to sleepiq (#163395) 2026-02-19 08:14:05 +01:00
Joost Lekkerkerker
2bd07e6626 Add integration_type hub to sensorpush_cloud (#163390) 2026-02-19 08:09:49 +01:00
johanzander
b91c07b2af Fix midnight bounce suppression for Growatt today sensors (#163106)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-02-19 09:07:52 +02:00
rhcp011235
37f0f1869f Add sleep health metrics to SleepIQ integration (#163403)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 01:02:43 +01:00
Manu
2fcbd77c95 Don't set last notification timestamp when sending message failed (#163251) 2026-02-19 00:48:01 +01:00
Josef Zweck
b398197c07 Debug logging for config_entries (#163378) 2026-02-19 00:46:06 +01:00
Joost Lekkerkerker
cd5775ca35 Add integration_type service to simplepush (#163394) 2026-02-19 00:37:17 +01:00
Christian Lackas
fafa193549 Add LED light support for WiredPushButton (HmIPW-WRC2/WRC6) (#161841)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-19 00:36:29 +01:00
elgris
ca4d537529 Control datetime on SwitchBot Meter Pro CO2 (#161808) 2026-02-19 00:32:23 +01:00
torben-iometer
e9be363f29 add support for multi tariff meter data in iometer (#161767)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-19 00:23:46 +01:00
Joshua Leaper
0f874f7f03 Add Config Flow for Ness Alarm (#162414)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-19 00:16:08 +01:00
Brett Adams
14b147b3f7 Mark Splunk dependency-transparency quality scale rule as done (#163355)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-02-19 00:11:10 +01:00
Brett Adams
8a1909e5d8 Bump hass-splunk to 0.1.4 (#163413) 2026-02-18 22:51:31 +00:00
Noah Husby
1fd873869f Bump aiostreammagic to 2.13.0 (#163408) 2026-02-18 22:49:18 +00:00
Robert Resch
3b7b3454d8 Simplify ecovacs unload and register teardown before initialize (#163350) 2026-02-18 23:32:39 +01:00
Josef Zweck
c7276621eb Add metadata validation for missing backup files in OneDrive backup agent (#163072) 2026-02-18 23:32:23 +01:00
Klaas Schoute
6be1e4065f Add Powerfox Local integration (#163302) 2026-02-18 23:27:47 +01:00
Artur Pragacz
ba547c6bdb Add channel muting switches to Onkyo (#162605)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-18 23:26:57 +01:00
mettolen
be25603b76 Refactor optimistic update and delayed refresh for Liebherr integration (#163121) 2026-02-18 23:11:47 +01:00
Joost Lekkerkerker
2e0f727981 Add integration_type hub to senz (#163391) 2026-02-18 23:11:29 +01:00
Joost Lekkerkerker
122bc32f30 Add integration_type device to sensorpush (#163389) 2026-02-18 23:11:01 +01:00
Brett Adams
723825b579 Mark runtime-data quality as exempt in Splunk (#163359)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 23:06:49 +01:00
rhcp011235
5f6b446195 Migrate SleepIQ sensors to entity descriptions (#163213)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 23:03:53 +01:00
Joost Lekkerkerker
f59f14fe40 Add integration_type device to sensorpro (#163386) 2026-02-18 21:49:12 +01:00
Joost Lekkerkerker
ab9b13302c Add integration_type hub to smarttub (#163399) 2026-02-18 21:47:19 +01:00
Joost Lekkerkerker
f74fdd7605 Add integration_type service to smhi (#163400) 2026-02-18 21:46:18 +01:00
Erwin Douna
f7628b87c8 Add ConfigEntryAuthFailed to Proxmox (#163407) 2026-02-18 21:43:04 +01:00
Karl Beecken
3e31fbfee0 Deduplicate strings in Teltonika integration (#163410) 2026-02-18 21:42:34 +01:00
Norbert Rittel
477797271a Replace "the" with "a" in vacuum action descriptions (#163409) 2026-02-18 21:41:00 +01:00
Andrew Jackson
9f2677ddd8 Add Mastodon mute/unmute actions (#163366) 2026-02-18 19:50:25 +01:00
Manu
558a49cb66 Fix data update in WebhookFlowHandler to preserve existing entry data (#163372) 2026-02-18 19:48:37 +01:00
Stefan Agner
a9b64a15e6 Redact Thread dataset and format them as readable dicts in log messages (#163385)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:41:36 +01:00
Andrew Jackson
0a734b7426 Improve Transmission error handling (#163388) 2026-02-18 19:41:28 +01:00
Steve Easley
8df41dc73f Bump Kaleidescape integration dependancy to v1.1.1 (#163384) 2026-02-18 19:41:17 +01:00
Glenn de Haan
e9039cec24 Add HDFury number platform (#163381)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-18 19:22:57 +01:00
Joost Lekkerkerker
15cb102c39 Bump pySmartThings to 3.5.3 (#163375)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-18 18:28:57 +01:00
Anthony Hou
30314ec88e Fix 0°C when the temperature is unavailable in HKO API (#162052) 2026-02-18 17:16:00 +00:00
rhcp011235
428aa31749 Update asyncsleepiq to 1.7.0 (#163214) 2026-02-18 16:44:02 +00:00
Joost Lekkerkerker
0170d56893 Add fixture to SmartThings (#163374) 2026-02-18 17:30:41 +01:00
epenet
eb7d973252 Ignore None keys in meteo_france extra state attributes (#163297) 2026-02-18 17:18:27 +01:00
epenet
e3c98dcd09 Use shorthand attributes in wirelesstag (#161214) 2026-02-18 17:14:06 +01:00
epenet
9c71aea622 Refactor extra_state_attributes in xiaomi_aqara (#163299) 2026-02-18 17:12:06 +01:00
epenet
21978917b9 Mark siren/stt/todo method type hints as mandatory (#163265) 2026-02-18 17:10:11 +01:00
puddly
3b6a5b2c79 Fix uses of reconfigure and re-configure in ZHA (#163377) 2026-02-18 11:05:05 -05:00
Ivan Dlugos
68792f02d4 Fix XMLParsedAsHTMLWarning in scrape integration (#159433)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
2026-02-18 17:00:49 +01:00
Josef Zweck
bfea04b482 Mark onedrive for business as platinum (#163376) 2026-02-18 16:53:07 +01:00
Erwin Douna
dc553f20e6 Ecovacs controller pattern optimization (#160895)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-18 16:49:34 +01:00
Manu
5631170900 Fix spelling of reconfigure in strings (#163370) 2026-02-18 16:36:31 +01:00
David Recordon
60d4b050ac Fix Control4 HVAC action mapping for multi-stage and idle states (#163222) 2026-02-18 16:35:57 +01:00
Josef Zweck
c5e261495f Add diagnostics to onedrive for business (#163336) 2026-02-18 16:35:32 +01:00
Erwin Douna
d1a1183b9a OAuth2.0 token request error handling (#153167)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-18 15:36:53 +01:00
Manu
4dcfd5fb91 Reconfiguration support for webhook flow helper (#151729) 2026-02-18 15:31:48 +01:00
Jochen Friedrich
680f7fac1c Fix MySensors battery sensors attachment to correct gateway (#151167)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-18 14:29:47 +01:00
Artur Pragacz
7a41ce1fd8 Add clean_area action to vacuum (#149315)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-02-18 14:13:08 +01:00
Erwin Douna
937b4866c3 Proxmox polish strings & tests (#163361) 2026-02-18 14:10:16 +01:00
Artur Pragacz
151e075e28 Do not send empty snapshots in analytics (#163351) 2026-02-18 13:45:45 +01:00
Erwin Douna
8094cfc404 Add coordinator to Proxmox (#161146) 2026-02-18 13:37:53 +01:00
Allen Porter
b26483e09e Fix remote calendar event handling of events within the same update period (#163186)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-18 12:52:35 +01:00
Brett Adams
728de32d75 Add missing data_description for reauth_confirm token in Splunk (#163356)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:43:44 +01:00
MoonDevLT
8de1e3d27b Change lunatone config entry title to only include the URL (#162855) 2026-02-18 12:27:25 +01:00
Tom Matheussen
cabf3b7ab9 Set last_reported timestamp for Satel Integra entities (#163352) 2026-02-18 12:04:30 +01:00
theobld-ww
f0e22cca56 Reconfiguration flow Watts Vision + and platinium level (#163346) 2026-02-18 11:55:27 +01:00
Karl Beecken
294a3e5360 add teltonika integration (#157539)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-18 11:18:50 +01:00
Nic Eggert
fdd753e70c Add support for voltage sensors to eGauge integration (#163206) 2026-02-18 08:44:01 +01:00
epenet
392fc7ff91 Use shorthand attributes in osramlightify (#163296) 2026-02-18 08:35:28 +01:00
Allen Porter
d777c1c542 Bump pyrainbird to 6.0.5 (#163333) 2026-02-18 08:19:38 +01:00
dependabot[bot]
fa71fd3992 Bump actions/stale from 10.1.1 to 10.2.0 (#163223) 2026-02-18 07:46:11 +01:00
Jamie Magee
19f6340546 Bump victron-ble-ha-parser to 0.4.10 (#163310) 2026-02-17 15:57:56 -05:00
Allen Porter
479cb7f1e1 Allow Gemini CLI and Anti-gravity SKILL discovery (#163194) 2026-02-17 21:50:38 +01:00
Manu
d50d914928 Update quality scale of Namecheap DynamicDNS integration to platinum 🏆️ (#161682) 2026-02-17 20:02:23 +00:00
Abílio Costa
551a71104e Bump Idasen Desk dependency (#163309) 2026-02-17 19:41:27 +00:00
Åke Strandberg
65cf61571a Add Miele dishwasher program code (#163308) 2026-02-17 19:36:58 +00:00
Simone Chemelli
58ac3d2f45 Type fixture in Fritz tests (#163271) 2026-02-17 18:32:35 +01:00
christian9712
654e132440 ADS Light Color Temperature Support (#153913)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 17:26:19 +01:00
hbludworth
4af60ef3b9 Show progress indicator during backup stage of Core/App update (#162683) 2026-02-17 17:24:06 +01:00
562 changed files with 25843 additions and 3591 deletions

1
.agent/skills Symbolic link
View File

@@ -0,0 +1 @@
../.claude/skills/

1
.gemini/skills Symbolic link
View File

@@ -0,0 +1 @@
../.claude/skills

View File

@@ -27,7 +27,7 @@ jobs:
# - No PRs marked as no-stale
# - No issues (-1)
- name: 60 days stale PRs policy
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60
@@ -67,7 +67,7 @@ jobs:
# - No issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: 90 days stale issues
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90
@@ -97,7 +97,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted
# - No PRs (-1)
- name: Needs more information stale issues policy
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information"

View File

@@ -49,6 +49,7 @@ homeassistant.components.actiontec.*
homeassistant.components.adax.*
homeassistant.components.adguard.*
homeassistant.components.aftership.*
homeassistant.components.ai_task.*
homeassistant.components.air_quality.*
homeassistant.components.airgradient.*
homeassistant.components.airly.*
@@ -130,6 +131,7 @@ homeassistant.components.bring.*
homeassistant.components.brother.*
homeassistant.components.browser.*
homeassistant.components.bryant_evolution.*
homeassistant.components.bsblan.*
homeassistant.components.bthome.*
homeassistant.components.button.*
homeassistant.components.calendar.*
@@ -209,6 +211,7 @@ homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
homeassistant.components.fritz.*
homeassistant.components.fritzbox.*
@@ -298,6 +301,7 @@ homeassistant.components.iotty.*
homeassistant.components.ipp.*
homeassistant.components.iqvia.*
homeassistant.components.iron_os.*
homeassistant.components.isal.*
homeassistant.components.islamic_prayer_times.*
homeassistant.components.isy994.*
homeassistant.components.jellyfin.*
@@ -308,6 +312,7 @@ homeassistant.components.knocki.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
homeassistant.components.kulersky.*
homeassistant.components.labs.*
homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*
@@ -367,6 +372,7 @@ homeassistant.components.my.*
homeassistant.components.mysensors.*
homeassistant.components.myuplink.*
homeassistant.components.nam.*
homeassistant.components.namecheapdns.*
homeassistant.components.nasweb.*
homeassistant.components.neato.*
homeassistant.components.nest.*
@@ -402,6 +408,7 @@ homeassistant.components.opnsense.*
homeassistant.components.opower.*
homeassistant.components.oralb.*
homeassistant.components.otbr.*
homeassistant.components.otp.*
homeassistant.components.overkiz.*
homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.*
@@ -418,6 +425,7 @@ homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerfox_local.*
homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
@@ -436,10 +444,12 @@ homeassistant.components.radarr.*
homeassistant.components.radio_browser.*
homeassistant.components.rainforest_raven.*
homeassistant.components.rainmachine.*
homeassistant.components.random.*
homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.*
homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
homeassistant.components.recovery_mode.*
homeassistant.components.redgtech.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.*
@@ -471,6 +481,7 @@ homeassistant.components.schlage.*
homeassistant.components.scrape.*
homeassistant.components.script.*
homeassistant.components.search.*
homeassistant.components.season.*
homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
@@ -522,6 +533,7 @@ homeassistant.components.synology_dsm.*
homeassistant.components.system_health.*
homeassistant.components.system_log.*
homeassistant.components.systemmonitor.*
homeassistant.components.systemnexa2.*
homeassistant.components.tag.*
homeassistant.components.tailscale.*
homeassistant.components.tailwind.*
@@ -564,6 +576,7 @@ homeassistant.components.update.*
homeassistant.components.uptime.*
homeassistant.components.uptime_kuma.*
homeassistant.components.uptimerobot.*
homeassistant.components.usage_prediction.*
homeassistant.components.usb.*
homeassistant.components.uvc.*
homeassistant.components.vacuum.*
@@ -582,6 +595,7 @@ homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.*
homeassistant.components.weather.*
homeassistant.components.web_rtc.*
homeassistant.components.webhook.*
homeassistant.components.webostv.*
homeassistant.components.websocket_api.*

16
CODEOWNERS generated
View File

@@ -792,8 +792,8 @@ build.json @home-assistant/supervisor
/tests/components/indevolt/ @xirtnl
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
/tests/components/influxdb/ @mdegat01 @Robbie1221
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core
@@ -1098,8 +1098,8 @@ build.json @home-assistant/supervisor
/tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444
/homeassistant/components/ness_alarm/ @nickw444 @poshy163
/tests/components/ness_alarm/ @nickw444 @poshy163
/homeassistant/components/nest/ @allenporter
/tests/components/nest/ @allenporter
/homeassistant/components/netatmo/ @cgtobi
@@ -1283,6 +1283,8 @@ build.json @home-assistant/supervisor
/tests/components/portainer/ @erwindouna
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
/tests/components/powerfox_local/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/prana/ @prana-dev-official
@@ -1646,6 +1648,8 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
/tests/components/systemnexa2/ @konsulten @slangstrom
/homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core
@@ -1671,6 +1675,8 @@ build.json @home-assistant/supervisor
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
@@ -1737,6 +1743,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/trane/ @bdraco
/tests/components/trane/ @bdraco
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/trend/ @jpbede

View File

@@ -0,0 +1,5 @@
{
"domain": "american_standard",
"name": "American Standard",
"integrations": ["nexia", "trane"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "powerfox",
"name": "Powerfox",
"integrations": ["powerfox", "powerfox_local"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "trane",
"name": "Trane",
"integrations": ["nexia", "trane"]
}

View File

@@ -9,9 +9,13 @@ import voluptuous as vol
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode,
LightEntity,
filter_supported_color_modes,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
@@ -24,13 +28,20 @@ from .entity import AdsEntity
from .hub import AdsHub
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin"
CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
STATE_KEY_BRIGHTNESS = "brightness"
STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin"
DEFAULT_NAME = "ADS Light"
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,
vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string,
vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}
)
@@ -47,9 +58,24 @@ def setup_platform(
ads_var_enable: str = config[CONF_ADS_VAR]
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN)
min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN)
max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN)
name: str = config[CONF_NAME]
add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
add_entities(
[
AdsLight(
ads_hub,
ads_var_enable,
ads_var_brightness,
ads_var_color_temp_kelvin,
min_color_temp_kelvin,
max_color_temp_kelvin,
name,
)
]
)
class AdsLight(AdsEntity, LightEntity):
@@ -60,18 +86,40 @@ class AdsLight(AdsEntity, LightEntity):
ads_hub: AdsHub,
ads_var_enable: str,
ads_var_brightness: str | None,
ads_var_color_temp_kelvin: str | None,
min_color_temp_kelvin: int | None,
max_color_temp_kelvin: int | None,
name: str,
) -> None:
"""Initialize AdsLight entity."""
super().__init__(ads_hub, name, ads_var_enable)
self._state_dict[STATE_KEY_BRIGHTNESS] = None
self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None
self._ads_var_brightness = ads_var_brightness
self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin
# Determine supported color modes
color_modes = {ColorMode.ONOFF}
if ads_var_brightness is not None:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
else:
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {ColorMode.ONOFF}
color_modes.add(ColorMode.BRIGHTNESS)
if ads_var_color_temp_kelvin is not None:
color_modes.add(ColorMode.COLOR_TEMP)
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
# Set color temperature range (static config values take precedence over defaults)
if ads_var_color_temp_kelvin is not None:
self._attr_min_color_temp_kelvin = (
min_color_temp_kelvin
if min_color_temp_kelvin is not None
else DEFAULT_MIN_KELVIN
)
self._attr_max_color_temp_kelvin = (
max_color_temp_kelvin
if max_color_temp_kelvin is not None
else DEFAULT_MAX_KELVIN
)
async def async_added_to_hass(self) -> None:
"""Register device notification."""
@@ -84,11 +132,23 @@ class AdsLight(AdsEntity, LightEntity):
STATE_KEY_BRIGHTNESS,
)
if self._ads_var_color_temp_kelvin is not None:
await self.async_initialize_device(
self._ads_var_color_temp_kelvin,
pyads.PLCTYPE_UINT,
STATE_KEY_COLOR_TEMP_KELVIN,
)
@property
def brightness(self) -> int | None:
"""Return the brightness of the light (0..255)."""
return self._state_dict[STATE_KEY_BRIGHTNESS]
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature in Kelvin."""
return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN]
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
@@ -97,6 +157,8 @@ class AdsLight(AdsEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on or set a specific dimmer value."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
if self._ads_var_brightness is not None and brightness is not None:
@@ -104,6 +166,11 @@ class AdsLight(AdsEntity, LightEntity):
self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT
)
if self._ads_var_color_temp_kelvin is not None and color_temp is not None:
self._ads_hub.write_by_name(
self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT
)
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)

View File

@@ -121,7 +121,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
return self.coordinator.data["myThings"]["things"][self._id]
@property
def is_on(self):
def is_on(self) -> bool:
"""Return if the thing is considered on."""
return self._data["value"] > 0

View File

@@ -1,8 +1,6 @@
rules:
# Bronze
action-setup:
status: todo
comment: https://developers.home-assistant.io/blog/2025/09/25/entity-services-api-changes/
action-setup: done
appropriate-polling: done
brands: done
common-modules: done

View File

@@ -534,6 +534,10 @@ class Analytics:
payload = await _async_snapshot_payload(self._hass)
if not payload:
LOGGER.info("Skipping snapshot submission, no data to send")
return
headers = {
"Content-Type": "application/json",
"User-Agent": f"home-assistant/{HA_VERSION}",

View File

@@ -19,7 +19,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CHAT_MODEL,
DATA_REPAIR_DEFER_RELOAD,
DEFAULT_CONVERSATION_NAME,
DEPRECATED_MODELS,
DOMAIN,
@@ -34,7 +33,6 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Anthropic."""
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
await async_migrate_integration(hass)
return True
@@ -85,11 +83,6 @@ async def async_update_options(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> None:
"""Update options."""
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
DATA_REPAIR_DEFER_RELOAD, set()
)
if entry.entry_id in defer_reload_entries:
return
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -23,8 +23,6 @@ CONF_WEB_SEARCH_REGION = "region"
CONF_WEB_SEARCH_COUNTRY = "country"
CONF_WEB_SEARCH_TIMEZONE = "timezone"
DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload"
DEFAULT = {
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_MAX_TOKENS: 3000,

View File

@@ -34,10 +34,7 @@ rules:
Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data:
status: todo
comment: |
To redesign deferred reloading.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done

View File

@@ -12,16 +12,14 @@ from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)
from .config_flow import get_model_list
from .const import (
CONF_CHAT_MODEL,
DATA_REPAIR_DEFER_RELOAD,
DEFAULT,
DEPRECATED_MODELS,
DOMAIN,
)
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -33,8 +31,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
_subentry_iter: Iterator[tuple[str, str]] | None
_current_entry_id: str | None
_current_subentry_id: str | None
_reload_pending: set[str]
_pending_updates: dict[str, dict[str, str]]
_model_list_cache: dict[str, list[SelectOptionDict]] | None
def __init__(self) -> None:
"""Initialize the flow."""
@@ -42,33 +39,32 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._subentry_iter = None
self._current_entry_id = None
self._current_subentry_id = None
self._reload_pending = set()
self._pending_updates = {}
self._model_list_cache = None
async def async_step_init(
self, user_input: dict[str, str] | None = None
self, user_input: dict[str, str]
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
previous_entry_id: str | None = None
if user_input is not None:
previous_entry_id = self._async_update_current_subentry(user_input)
self._clear_current_target()
"""Handle the steps of a fix flow."""
if user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
target = await self._async_next_target()
next_entry_id = target[0].entry_id if target else None
if previous_entry_id and previous_entry_id != next_entry_id:
await self._async_apply_pending_updates(previous_entry_id)
if target is None:
await self._async_apply_all_pending_updates()
return self.async_create_entry(data={})
entry, subentry, model = target
client = entry.runtime_data
model_list = [
model_option
for model_option in await get_model_list(client)
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
if self._model_list_cache is None:
self._model_list_cache = {}
if entry.entry_id in self._model_list_cache:
model_list = self._model_list_cache[entry.entry_id]
else:
client = entry.runtime_data
model_list = [
model_option
for model_option in await get_model_list(client)
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
]
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
suggested_model = "claude-opus-4-5"
@@ -124,6 +120,8 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
except StopIteration:
return None
# Verify that the entry/subentry still exists and the model is still
# deprecated. This may have changed since we started the repair flow.
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None:
continue
@@ -132,9 +130,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
if subentry is None:
continue
model = self._pending_model(entry_id, subentry_id)
if model is None:
model = subentry.data.get(CONF_CHAT_MODEL)
model = subentry.data.get(CONF_CHAT_MODEL)
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
continue
@@ -142,36 +138,30 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._current_subentry_id = subentry_id
return entry, subentry, model
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
def _async_update_current_subentry(self, user_input: dict[str, str]) -> None:
"""Update the currently selected subentry."""
if not self._current_entry_id or not self._current_subentry_id:
return None
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
if entry is None:
return None
subentry = entry.subentries.get(self._current_subentry_id)
if subentry is None:
return None
if (
self._current_entry_id is None
or self._current_subentry_id is None
or (
entry := self.hass.config_entries.async_get_entry(
self._current_entry_id
)
)
is None
or (subentry := entry.subentries.get(self._current_subentry_id)) is None
):
raise HomeAssistantError("Subentry not found")
updated_data = {
**subentry.data,
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
}
if updated_data == subentry.data:
return entry.entry_id
self._queue_pending_update(
entry.entry_id,
subentry.subentry_id,
updated_data[CONF_CHAT_MODEL],
self.hass.config_entries.async_update_subentry(
entry,
subentry,
data=updated_data,
)
return entry.entry_id
def _clear_current_target(self) -> None:
"""Clear current target tracking."""
self._current_entry_id = None
self._current_subentry_id = None
def _format_subentry_type(self, subentry_type: str) -> str:
"""Return a user-friendly subentry type label."""
@@ -181,91 +171,6 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
return "AI task"
return subentry_type
def _queue_pending_update(
self, entry_id: str, subentry_id: str, model: str
) -> None:
"""Store a pending model update for a subentry."""
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
"""Return a pending model update if one exists."""
return self._pending_updates.get(entry_id, {}).get(subentry_id)
def _mark_entry_for_reload(self, entry_id: str) -> None:
"""Prevent reload until repairs are complete for the entry."""
self._reload_pending.add(entry_id)
defer_reload_entries: set[str] = self.hass.data.setdefault(
DOMAIN, {}
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
defer_reload_entries.add(entry_id)
async def _async_reload_entry(self, entry_id: str) -> None:
"""Reload an entry once all repairs are completed."""
if entry_id not in self._reload_pending:
return
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is not None and entry.state is not ConfigEntryState.LOADED:
self._clear_defer_reload(entry_id)
self._reload_pending.discard(entry_id)
return
if entry is not None:
await self.hass.config_entries.async_reload(entry_id)
self._clear_defer_reload(entry_id)
self._reload_pending.discard(entry_id)
def _clear_defer_reload(self, entry_id: str) -> None:
"""Remove entry from the deferred reload set."""
defer_reload_entries: set[str] = self.hass.data.setdefault(
DOMAIN, {}
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
defer_reload_entries.discard(entry_id)
async def _async_apply_pending_updates(self, entry_id: str) -> None:
"""Apply pending subentry updates for a single entry."""
updates = self._pending_updates.pop(entry_id, None)
if not updates:
return
entry = self.hass.config_entries.async_get_entry(entry_id)
if entry is None or entry.state is not ConfigEntryState.LOADED:
return
changed = False
for subentry_id, model in updates.items():
subentry = entry.subentries.get(subentry_id)
if subentry is None:
continue
updated_data = {
**subentry.data,
CONF_CHAT_MODEL: model,
}
if updated_data == subentry.data:
continue
if not changed:
self._mark_entry_for_reload(entry_id)
changed = True
self.hass.config_entries.async_update_subentry(
entry,
subentry,
data=updated_data,
)
if not changed:
return
await self._async_reload_entry(entry_id)
async def _async_apply_all_pending_updates(self) -> None:
"""Apply all pending updates across entries."""
for entry_id in list(self._pending_updates):
await self._async_apply_pending_updates(entry_id)
async def async_create_fix_flow(
hass: HomeAssistant,

View File

@@ -5,11 +5,10 @@ from __future__ import annotations
import logging
from typing import cast
from aiobotocore.client import AioBaseClient as S3Client
from aiobotocore.session import AioSession
from botocore.exceptions import ClientError, ConnectionError, ParamValidationError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
@@ -21,9 +20,9 @@ from .const import (
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
)
from .coordinator import S3ConfigEntry, S3DataUpdateCoordinator
type S3ConfigEntry = ConfigEntry[S3Client]
_PLATFORMS = (Platform.SENSOR,)
_LOGGER = logging.getLogger(__name__)
@@ -64,7 +63,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
translation_key="cannot_connect",
) from err
entry.runtime_data = client
coordinator = S3DataUpdateCoordinator(
hass,
entry=entry,
client=client,
)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
def notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
@@ -72,11 +77,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
entry.async_on_unload(entry.async_on_state_change(notify_backup_listeners))
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
"""Unload a config entry."""
client = entry.runtime_data
await client.__aexit__(None, None, None)
unload_ok = await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
if not unload_ok:
return False
coordinator = entry.runtime_data
await coordinator.client.__aexit__(None, None, None)
return True

View File

@@ -20,6 +20,7 @@ from homeassistant.core import HomeAssistant, callback
from . import S3ConfigEntry
from .const import CONF_BUCKET, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .helpers import async_list_backups_from_s3
_LOGGER = logging.getLogger(__name__)
CACHE_TTL = 300
@@ -93,7 +94,7 @@ class S3BackupAgent(BackupAgent):
def __init__(self, hass: HomeAssistant, entry: S3ConfigEntry) -> None:
"""Initialize the S3 agent."""
super().__init__()
self._client = entry.runtime_data
self._client = entry.runtime_data.client
self._bucket: str = entry.data[CONF_BUCKET]
self.name = entry.title
self.unique_id = entry.entry_id
@@ -316,35 +317,8 @@ class S3BackupAgent(BackupAgent):
if time() <= self._cache_expiration:
return self._backup_cache
backups = {}
paginator = self._client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=self._bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
for metadata_file in metadata_files:
try:
# Download and parse metadata file
metadata_response = await self._client.get_object(
Bucket=self._bucket, Key=metadata_file["Key"]
)
metadata_content = await metadata_response["Body"].read()
metadata_json = json.loads(metadata_content)
except (BotoCoreError, json.JSONDecodeError) as err:
_LOGGER.warning(
"Failed to process metadata file %s: %s",
metadata_file["Key"],
err,
)
continue
backup = AgentBackup.from_dict(metadata_json)
backups[backup.backup_id] = backup
self._backup_cache = backups
backups_list = await async_list_backups_from_s3(self._client, self._bucket)
self._backup_cache = {b.backup_id: b for b in backups_list}
self._cache_expiration = time() + CACHE_TTL
return self._backup_cache

View File

@@ -0,0 +1,70 @@
"""DataUpdateCoordinator for AWS S3."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiobotocore.client import AioBaseClient as S3Client
from botocore.exceptions import BotoCoreError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_BUCKET, DOMAIN
from .helpers import async_list_backups_from_s3
SCAN_INTERVAL = timedelta(hours=6)
type S3ConfigEntry = ConfigEntry[S3DataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
@dataclass
class SensorData:
"""Class to represent sensor data."""
all_backups_size: int
class S3DataUpdateCoordinator(DataUpdateCoordinator[SensorData]):
"""Class to manage fetching AWS S3 data from single endpoint."""
config_entry: S3ConfigEntry
client: S3Client
def __init__(
self,
hass: HomeAssistant,
*,
entry: S3ConfigEntry,
client: S3Client,
) -> None:
"""Initialize AWS S3 data updater."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
self.client = client
self._bucket: str = entry.data[CONF_BUCKET]
async def _async_update_data(self) -> SensorData:
"""Fetch data from AWS S3."""
try:
backups = await async_list_backups_from_s3(self.client, self._bucket)
except BotoCoreError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_fetching_data",
) from error
all_backups_size = sum(b.size for b in backups)
return SensorData(
all_backups_size=all_backups_size,
)

View File

@@ -0,0 +1,33 @@
"""Define the AWS S3 entity."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_BUCKET, DOMAIN
from .coordinator import S3DataUpdateCoordinator
class S3Entity(CoordinatorEntity[S3DataUpdateCoordinator]):
"""Defines a base AWS S3 entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: S3DataUpdateCoordinator, description: EntityDescription
) -> None:
"""Initialize an AWS S3 entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
@property
def device_info(self) -> DeviceInfo:
"""Return device information about this AWS S3 device."""
return DeviceInfo(
identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)},
name=f"Bucket {self.coordinator.config_entry.data[CONF_BUCKET]}",
manufacturer="AWS",
model="AWS S3",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -0,0 +1,57 @@
"""Helpers for the AWS S3 integration."""
from __future__ import annotations
import json
import logging
from typing import Any
from aiobotocore.client import AioBaseClient as S3Client
from botocore.exceptions import BotoCoreError
from homeassistant.components.backup import AgentBackup
_LOGGER = logging.getLogger(__name__)
async def async_list_backups_from_s3(
client: S3Client,
bucket: str,
) -> list[AgentBackup]:
"""List backups from an S3 bucket by reading metadata files."""
paginator = client.get_paginator("list_objects_v2")
metadata_files: list[dict[str, Any]] = []
async for page in paginator.paginate(Bucket=bucket):
metadata_files.extend(
obj
for obj in page.get("Contents", [])
if obj["Key"].endswith(".metadata.json")
)
backups: list[AgentBackup] = []
for metadata_file in metadata_files:
try:
metadata_response = await client.get_object(
Bucket=bucket, Key=metadata_file["Key"]
)
metadata_content = await metadata_response["Body"].read()
metadata_json = json.loads(metadata_content)
except (BotoCoreError, json.JSONDecodeError) as err:
_LOGGER.warning(
"Failed to process metadata file %s: %s",
metadata_file["Key"],
err,
)
continue
try:
backup = AgentBackup.from_dict(metadata_json)
except (KeyError, TypeError, ValueError) as err:
_LOGGER.warning(
"Failed to parse metadata in file %s: %s",
metadata_file["Key"],
err,
)
continue
backups.append(backup)
return backups

View File

@@ -3,9 +3,10 @@
"name": "AWS S3",
"codeowners": ["@tomasbedrich"],
"config_flow": true,
"dependencies": ["backup"],
"documentation": "https://www.home-assistant.io/integrations/aws_s3",
"integration_type": "service",
"iot_class": "cloud_push",
"iot_class": "cloud_polling",
"loggers": ["aiobotocore"],
"quality_scale": "bronze",
"requirements": ["aiobotocore==2.21.1"]

View File

@@ -3,9 +3,7 @@ rules:
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: exempt
comment: This integration does not poll.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
@@ -20,12 +18,8 @@ rules:
entity-event-setup:
status: exempt
comment: Entities of this integration does not explicitly subscribe to events.
entity-unique-id:
status: exempt
comment: This integration does not have entities.
has-entity-name:
status: exempt
comment: This integration does not have entities.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
@@ -40,21 +34,15 @@ rules:
status: exempt
comment: This integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: This integration does not have entities.
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: exempt
comment: This integration does not poll.
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices:
status: exempt
comment: This integration does not have entities.
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
@@ -62,15 +50,11 @@ rules:
discovery:
status: exempt
comment: S3 is a cloud service that is not discovered on the network.
docs-data-update:
status: exempt
comment: This integration does not poll.
docs-data-update: done
docs-examples:
status: exempt
comment: The integration extends core functionality and does not require examples.
docs-known-limitations:
status: exempt
comment: No known limitations.
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: This integration does not support physical devices.
@@ -81,19 +65,11 @@ rules:
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This integration does not have devices.
entity-category:
status: exempt
comment: This integration does not have entities.
entity-device-class:
status: exempt
comment: This integration does not have entities.
entity-disabled-by-default:
status: exempt
comment: This integration does not have entities.
entity-translations:
status: exempt
comment: This integration does not have entities.
comment: This integration has a fixed set of devices.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations:
status: exempt
@@ -104,7 +80,7 @@ rules:
comment: There are no issues which can be repaired.
stale-devices:
status: exempt
comment: This integration does not have devices.
comment: This is a service type integration with a single device.
# Platinum
async-dependency: done

View File

@@ -0,0 +1,66 @@
"""Support for AWS S3 sensors."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import S3ConfigEntry, SensorData
from .entity import S3Entity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class S3SensorEntityDescription(SensorEntityDescription):
"""Describes an AWS S3 sensor entity."""
value_fn: Callable[[SensorData], StateType]
SENSORS: tuple[S3SensorEntityDescription, ...] = (
S3SensorEntityDescription(
key="backups_size",
translation_key="backups_size",
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
device_class=SensorDeviceClass.DATA_SIZE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.all_backups_size,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: S3ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AWS S3 sensor based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
S3SensorEntity(coordinator, description) for description in SENSORS
)
class S3SensorEntity(S3Entity, SensorEntity):
"""Defines an AWS S3 sensor entity."""
entity_description: S3SensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -27,10 +27,20 @@
}
}
},
"entity": {
"sensor": {
"backups_size": {
"name": "Total size of backups"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Cannot connect to endpoint"
},
"error_fetching_data": {
"message": "Error fetching data"
},
"invalid_bucket_name": {
"message": "Invalid bucket name"
},

View File

@@ -34,7 +34,7 @@ class BleBoxSwitchEntity(BleBoxEntity[blebox_uniapi.switch.Switch], SwitchEntity
_attr_device_class = SwitchDeviceClass.SWITCH
@property
def is_on(self):
def is_on(self) -> bool | None:
"""Return whether switch is on."""
return self._feature.is_on

View File

@@ -77,7 +77,7 @@ class ShutterContactSensor(SHCEntity, BinarySensorEntity):
)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return the state of the sensor."""
return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN
@@ -93,7 +93,7 @@ class BatterySensor(SHCEntity, BinarySensorEntity):
self._attr_unique_id = f"{device.serial}_battery"
@property
def is_on(self):
def is_on(self) -> bool:
"""Return the state of the sensor."""
return (
self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK

View File

@@ -101,16 +101,16 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.coordinator.data.state.current_temperature is None:
if (current_temp := self.coordinator.data.state.current_temperature) is None:
return None
return self.coordinator.data.state.current_temperature.value
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if self.coordinator.data.state.target_temperature is None:
if (target_temp := self.coordinator.data.state.target_temperature) is None:
return None
return self.coordinator.data.state.target_temperature.value
return target_temp.value
@property
def _hvac_mode_value(self) -> int | str | None:

View File

@@ -1,7 +1,10 @@
"""DataUpdateCoordinator for the BSB-Lan integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import TYPE_CHECKING
from bsblan import (
BSBLAN,
@@ -14,7 +17,6 @@ from bsblan import (
State,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -22,6 +24,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
if TYPE_CHECKING:
from . import BSBLanConfigEntry
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
@@ -54,12 +59,12 @@ class BSBLanSlowData:
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
"""Base BSB-Lan coordinator."""
config_entry: ConfigEntry
config_entry: BSBLanConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
name: str,
update_interval: timedelta,
@@ -81,7 +86,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan fast coordinator."""
@@ -126,7 +131,7 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan slow coordinator."""

View File

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

View File

@@ -81,58 +81,57 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
self._attr_available = True
# Set temperature limits based on device capabilities from slow coordinator
dhw_config = (
data.slow_coordinator.data.dhw_config
if data.slow_coordinator.data
else None
)
# For min_temp: Use reduced_setpoint from config data (slow polling)
if (
data.slow_coordinator.data
and data.slow_coordinator.data.dhw_config is not None
and data.slow_coordinator.data.dhw_config.reduced_setpoint is not None
and hasattr(data.slow_coordinator.data.dhw_config.reduced_setpoint, "value")
dhw_config is not None
and dhw_config.reduced_setpoint is not None
and dhw_config.reduced_setpoint.value is not None
):
self._attr_min_temp = float(
data.slow_coordinator.data.dhw_config.reduced_setpoint.value
)
self._attr_min_temp = dhw_config.reduced_setpoint.value
else:
self._attr_min_temp = 10.0 # Default minimum
# For max_temp: Use nominal_setpoint_max from config data (slow polling)
if (
data.slow_coordinator.data
and data.slow_coordinator.data.dhw_config is not None
and data.slow_coordinator.data.dhw_config.nominal_setpoint_max is not None
and hasattr(
data.slow_coordinator.data.dhw_config.nominal_setpoint_max, "value"
)
dhw_config is not None
and dhw_config.nominal_setpoint_max is not None
and dhw_config.nominal_setpoint_max.value is not None
):
self._attr_max_temp = float(
data.slow_coordinator.data.dhw_config.nominal_setpoint_max.value
)
self._attr_max_temp = dhw_config.nominal_setpoint_max.value
else:
self._attr_max_temp = 65.0 # Default maximum
@property
def current_operation(self) -> str | None:
"""Return current operation."""
if self.coordinator.data.dhw.operating_mode is None:
if (operating_mode := self.coordinator.data.dhw.operating_mode) is None:
return None
# The operating_mode.value is an integer (0=Off, 1=On, 2=Eco)
current_mode_value = self.coordinator.data.dhw.operating_mode.value
if isinstance(current_mode_value, int):
return BSBLAN_TO_HA_OPERATION_MODE.get(current_mode_value)
if isinstance(operating_mode.value, int):
return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value)
return None
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.coordinator.data.dhw.dhw_actual_value_top_temperature is None:
if (
current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature
) is None:
return None
return self.coordinator.data.dhw.dhw_actual_value_top_temperature.value
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if self.coordinator.data.dhw.nominal_setpoint is None:
if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None:
return None
return self.coordinator.data.dhw.nominal_setpoint.value
return target_temp.value
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""

View File

@@ -16,7 +16,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.MEDIA_PLAYER,
Platform.NUMBER,
Platform.SELECT,
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)

View File

@@ -1,5 +1,10 @@
{
"entity": {
"number": {
"room_correction_intensity": {
"default": "mdi:home-sound-out"
}
},
"select": {
"audio_output": {
"default": "mdi:audio-input-stereo-minijack"

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.12.1"],
"requirements": ["aiostreammagic==2.13.0"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}

View File

@@ -0,0 +1,88 @@
"""Support for Cambridge Audio number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from aiostreammagic import StreamMagicClient
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CambridgeAudioConfigEntry
from .entity import CambridgeAudioEntity, command
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class CambridgeAudioNumberEntityDescription(NumberEntityDescription):
"""Describes Cambridge Audio number entity."""
exists_fn: Callable[[StreamMagicClient], bool] = lambda _: True
value_fn: Callable[[StreamMagicClient], int]
set_value_fn: Callable[[StreamMagicClient, int], Awaitable[None]]
def room_correction_intensity(client: StreamMagicClient) -> int:
"""Get room correction intensity."""
if TYPE_CHECKING:
assert client.audio.tilt_eq is not None
return client.audio.tilt_eq.intensity
CONTROL_ENTITIES: tuple[CambridgeAudioNumberEntityDescription, ...] = (
CambridgeAudioNumberEntityDescription(
key="room_correction_intensity",
translation_key="room_correction_intensity",
entity_category=EntityCategory.CONFIG,
native_min_value=-15,
native_max_value=15,
native_step=1,
exists_fn=lambda client: client.audio.tilt_eq is not None,
value_fn=room_correction_intensity,
set_value_fn=lambda client, value: client.set_room_correction_intensity(value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CambridgeAudioConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cambridge Audio number entities based on a config entry."""
client = entry.runtime_data
async_add_entities(
CambridgeAudioNumber(entry.runtime_data, description)
for description in CONTROL_ENTITIES
if description.exists_fn(client)
)
class CambridgeAudioNumber(CambridgeAudioEntity, NumberEntity):
"""Defines a Cambridge Audio number entity."""
entity_description: CambridgeAudioNumberEntityDescription
def __init__(
self,
client: StreamMagicClient,
description: CambridgeAudioNumberEntityDescription,
) -> None:
"""Initialize Cambridge Audio number entity."""
super().__init__(client)
self.entity_description = description
self._attr_unique_id = f"{client.info.unit_id}-{description.key}"
@property
def native_value(self) -> int | None:
"""Return the state of the number."""
return self.entity_description.value_fn(self.client)
@command
async def async_set_native_value(self, value: float) -> None:
"""Set the selected value."""
await self.entity_description.set_value_fn(self.client, int(value))

View File

@@ -35,6 +35,11 @@
}
},
"entity": {
"number": {
"room_correction_intensity": {
"name": "Room correction intensity"
}
},
"select": {
"audio_output": {
"name": "Audio output"

View File

@@ -75,11 +75,12 @@ C4_TO_HA_HVAC_MODE = {
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
# Map Control4 HVAC states to Home Assistant HVAC actions
C4_TO_HA_HVAC_ACTION = {
"off": HVACAction.OFF,
"heat": HVACAction.HEATING,
"cool": HVACAction.COOLING,
"idle": HVACAction.IDLE,
"dry": HVACAction.DRYING,
"fan": HVACAction.FAN,
}
@@ -292,8 +293,14 @@ class Control4Climate(Control4Entity, ClimateEntity):
c4_state = data.get(CONTROL4_HVAC_STATE)
if c4_state is None:
return None
# Convert state to lowercase for mapping
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
# Substring match for multi-stage systems that report
# e.g. "Stage 1 Heat", "Stage 2 Cool"
if action is None:
if "heat" in str(c4_state).lower():
action = HVACAction.HEATING
elif "cool" in str(c4_state).lower():
action = HVACAction.COOLING
if action is None:
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
return action

View File

@@ -189,7 +189,7 @@ class Control4Light(Control4Entity, LightEntity):
return C4Light(self.runtime_data.director, self._idx)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return whether this light is on or off."""
if self._is_dimmer:
for var in CONTROL4_DIMMER_VARS:

View File

@@ -59,21 +59,10 @@ class DanfossAir(SwitchEntity):
def __init__(self, data, name, state_command, on_command, off_command):
"""Initialize the switch."""
self._data = data
self._name = name
self._attr_name = name
self._state_command = state_command
self._on_command = on_command
self._off_command = off_command
self._state = None
@property
def name(self):
"""Return the name of the switch."""
return self._name
@property
def is_on(self):
"""Return true if switch is on."""
return self._state
def turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
@@ -89,6 +78,6 @@ class DanfossAir(SwitchEntity):
"""Update the switch's state."""
self._data.update()
self._state = self._data.get_value(self._state_command)
if self._state is None:
self._attr_is_on = self._data.get_value(self._state_command)
if self._attr_is_on is None:
_LOGGER.debug("Could not get data for %s", self._state_command)

View File

@@ -137,7 +137,7 @@ class DecoraWifiLight(LightEntity):
return int(self._switch.brightness * 255 / 100)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if switch is on."""
return self._switch.power == "ON"

View File

@@ -7,6 +7,7 @@ from typing import Any
from homeassistant.components.vacuum import (
ATTR_CLEANED_AREA,
Segment,
StateVacuumEntity,
VacuumActivity,
VacuumEntityFeature,
@@ -14,8 +15,11 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import event
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
SUPPORT_BASIC_SERVICES = (
@@ -45,9 +49,17 @@ SUPPORT_ALL_SERVICES = (
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.MAP
| VacuumEntityFeature.CLEAN_SPOT
| VacuumEntityFeature.CLEAN_AREA
)
FAN_SPEEDS = ["min", "medium", "high", "max"]
DEMO_SEGMENTS = [
Segment(id="living_room", name="Living room"),
Segment(id="kitchen", name="Kitchen"),
Segment(id="bedroom_1", name="Master bedroom", group="Bedrooms"),
Segment(id="bedroom_2", name="Guest bedroom", group="Bedrooms"),
Segment(id="bathroom", name="Bathroom"),
]
DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor"
DEMO_VACUUM_MOST = "Demo vacuum 1 first floor"
DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor"
@@ -63,11 +75,11 @@ async def async_setup_entry(
"""Set up the Demo config entry."""
async_add_entities(
[
StateDemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
StateDemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
StateDemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES),
StateDemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES),
StateDemoVacuum(DEMO_VACUUM_NONE, VacuumEntityFeature(0)),
StateDemoVacuum("vacuum_1", DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
StateDemoVacuum("vacuum_2", DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
StateDemoVacuum("vacuum_3", DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES),
StateDemoVacuum("vacuum_4", DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES),
StateDemoVacuum("vacuum_5", DEMO_VACUUM_NONE, VacuumEntityFeature(0)),
]
)
@@ -75,13 +87,21 @@ async def async_setup_entry(
class StateDemoVacuum(StateVacuumEntity):
"""Representation of a demo vacuum supporting states."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_translation_key = "model_s"
def __init__(self, name: str, supported_features: VacuumEntityFeature) -> None:
def __init__(
self, unique_id: str, name: str, supported_features: VacuumEntityFeature
) -> None:
"""Initialize the vacuum."""
self._attr_name = name
self._attr_unique_id = unique_id
self._attr_supported_features = supported_features
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=name,
)
self._attr_activity = VacuumActivity.DOCKED
self._fan_speed = FAN_SPEEDS[1]
self._cleaned_area: float = 0
@@ -163,6 +183,16 @@ class StateDemoVacuum(StateVacuumEntity):
self._attr_activity = VacuumActivity.IDLE
self.async_write_ha_state()
async def async_get_segments(self) -> list[Segment]:
"""Get the list of segments."""
return DEMO_SEGMENTS
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Clean the specified segments."""
self._attr_activity = VacuumActivity.CLEANING
self._cleaned_area += len(segment_ids) * 0.7
self.async_write_ha_state()
def __set_state_to_dock(self, _: datetime) -> None:
self._attr_activity = VacuumActivity.DOCKED
self.schedule_update_ha_state()

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the [webhook service of Dialogflow]({dialogflow_url}) and update the webhook with following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details.",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
},
@@ -9,6 +10,10 @@
"default": "To send events to Home Assistant, you will need to set up the [webhook service of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details."
},
"step": {
"reconfigure": {
"description": "Are you sure you want to reconfigure Dialogflow?",
"title": "Reconfigure Dialogflow webhook"
},
"user": {
"description": "Are you sure you want to set up Dialogflow?",
"title": "Set up the Dialogflow webhook"

View File

@@ -11,8 +11,7 @@ ATTR_FILENAME = "filename"
ATTR_SUBDIR = "subdir"
ATTR_URL = "url"
ATTR_OVERWRITE = "overwrite"
CONF_DOWNLOAD_DIR = "download_dir"
ATTR_HEADERS = "headers"
DOWNLOAD_FAILED_EVENT = "download_failed"
DOWNLOAD_COMPLETED_EVENT = "download_completed"

View File

@@ -19,6 +19,7 @@ from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
from .const import (
_LOGGER,
ATTR_FILENAME,
ATTR_HEADERS,
ATTR_OVERWRITE,
ATTR_SUBDIR,
ATTR_URL,
@@ -39,6 +40,7 @@ def download_file(service: ServiceCall) -> None:
subdir: str | None = service.data.get(ATTR_SUBDIR)
target_filename: str | None = service.data.get(ATTR_FILENAME)
overwrite: bool = service.data[ATTR_OVERWRITE]
headers: dict[str, str] = service.data[ATTR_HEADERS]
if subdir:
# Check the path
@@ -62,7 +64,7 @@ def download_file(service: ServiceCall) -> None:
final_path = None
filename = target_filename
try:
req = requests.get(url, stream=True, timeout=10)
req = requests.get(url, stream=True, headers=headers, timeout=10)
if req.status_code != HTTPStatus.OK:
_LOGGER.warning(
@@ -162,6 +164,9 @@ def async_setup_services(hass: HomeAssistant) -> None:
vol.Optional(ATTR_SUBDIR): cv.string,
vol.Required(ATTR_URL): cv.url,
vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
vol.Optional(ATTR_HEADERS, default=dict): vol.Schema(
{cv.string: cv.string}
),
}
),
)

View File

@@ -17,3 +17,9 @@ download_file:
default: false
selector:
boolean:
headers:
default: {}
example:
Accept: application/json
selector:
object:

View File

@@ -28,6 +28,10 @@
"description": "Custom name for the downloaded file.",
"name": "Filename"
},
"headers": {
"description": "Additional custom HTTP headers.",
"name": "Headers"
},
"overwrite": {
"description": "Overwrite file if it exists.",
"name": "Overwrite"

View File

@@ -38,3 +38,18 @@ def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)
def action_called_without_config_entry(hass: HomeAssistant) -> None:
"""Deprecate the use of action without config entry."""
async_create_issue(
hass,
DOMAIN,
"deprecated_call_without_config_entry",
breaks_in_ha_version="2026.9.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_call_without_config_entry",
)

View File

@@ -15,6 +15,7 @@ from homeassistant.helpers.selector import ConfigEntrySelector
from .const import ATTR_CONFIG_ENTRY, ATTR_TXT, DOMAIN, SERVICE_SET_TXT
from .coordinator import DuckDnsConfigEntry
from .helpers import update_duckdns
from .issue import action_called_without_config_entry
SERVICE_TXT_SCHEMA = vol.Schema(
{
@@ -42,6 +43,7 @@ def get_config_entry(
"""Return config entry or raise if not found or not loaded."""
if entry_id is None:
action_called_without_config_entry(hass)
if len(entries := hass.config_entries.async_entries(DOMAIN)) != 1:
raise ServiceValidationError(
translation_domain=DOMAIN,

View File

@@ -16,7 +16,7 @@
"data_description": {
"access_token": "[%key:component::duckdns::config::step::user::data_description::access_token%]"
},
"title": "Re-configure {name}"
"title": "Reconfigure {name}"
},
"user": {
"data": {
@@ -46,6 +46,10 @@
}
},
"issues": {
"deprecated_call_without_config_entry": {
"description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.",
"title": "Detected deprecated use of action without config entry"
},
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"

View File

@@ -74,6 +74,6 @@ class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity):
)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return getattr(self._econet, self.entity_description.key)

View File

@@ -38,12 +38,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
"""Set up this integration using UI."""
controller = EcovacsController(hass, entry.data)
entry.async_on_unload(controller.teardown)
await controller.initialize()
async def on_unload() -> None:
await controller.teardown()
entry.async_on_unload(on_unload)
entry.runtime_data = controller
async def _async_wait_connect(device: VacBot) -> None:

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from functools import partial
import logging
@@ -80,11 +81,22 @@ class EcovacsController:
try:
devices = await self._api_client.get_devices()
credentials = await self._authenticator.authenticate()
for device_info in devices.mqtt:
device = Device(device_info, self._authenticator)
if devices.mqtt:
mqtt = await self._get_mqtt_client()
await device.initialize(mqtt)
self._devices.append(device)
mqtt_devices = [
Device(info, self._authenticator) for info in devices.mqtt
]
async with asyncio.TaskGroup() as tg:
async def _init(device: Device) -> None:
"""Initialize MQTT device."""
await device.initialize(mqtt)
self._devices.append(device)
for device in mqtt_devices:
tg.create_task(_init(device))
for device_config in devices.xmpp:
bot = VacBot(
credentials.user_id,

View File

@@ -53,25 +53,9 @@ class SmartPlugSwitch(SwitchEntity):
def __init__(self, smartplug, name):
"""Initialize the switch."""
self.smartplug = smartplug
self._name = name
self._state = False
self._attr_name = name
self._attr_is_on = False
self._info = None
self._mac = None
@property
def unique_id(self):
"""Return the device's MAC address."""
return self._mac
@property
def name(self):
"""Return the name of the Smart Plug, if any."""
return self._name
@property
def is_on(self):
"""Return true if switch is on."""
return self._state
def turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
@@ -85,6 +69,6 @@ class SmartPlugSwitch(SwitchEntity):
"""Update edimax switch."""
if not self._info:
self._info = self.smartplug.info
self._mac = self._info["mac"]
self._attr_unique_id = self._info["mac"]
self._state = self.smartplug.state == "ON"
self._attr_is_on = self.smartplug.state == "ON"

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from egauge_async.json.models import RegisterType
from egauge_async.json.models import RegisterInfo, RegisterType
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -27,6 +27,7 @@ class EgaugeSensorEntityDescription(SensorEntityDescription):
native_value_fn: Callable[[EgaugeData, str], float]
available_fn: Callable[[EgaugeData, str], bool]
supported_fn: Callable[[RegisterInfo], bool]
SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
@@ -37,6 +38,7 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
native_value_fn=lambda data, register: data.measurements[register],
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.POWER,
),
EgaugeSensorEntityDescription(
key="energy",
@@ -46,6 +48,16 @@ SENSORS: tuple[EgaugeSensorEntityDescription, ...] = (
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_value_fn=lambda data, register: data.counters[register],
available_fn=lambda data, register: register in data.counters,
supported_fn=lambda register_info: register_info.type == RegisterType.POWER,
),
EgaugeSensorEntityDescription(
key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
native_value_fn=lambda data, register: data.measurements[register],
available_fn=lambda data, register: register in data.measurements,
supported_fn=lambda register_info: register_info.type == RegisterType.VOLTAGE,
),
)
@@ -61,7 +73,7 @@ async def async_setup_entry(
EgaugeSensor(coordinator, register_name, sensor)
for sensor in SENSORS
for register_name, register_info in coordinator.data.register_info.items()
if register_info.type == RegisterType.POWER
if sensor.supported_fn(register_info)
)

View File

@@ -16,8 +16,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "PCA 301"
def setup_platform(
hass: HomeAssistant,
@@ -54,26 +52,9 @@ class SmartPlugSwitch(SwitchEntity):
def __init__(self, pca, device_id):
"""Initialize the switch."""
self._device_id = device_id
self._name = "PCA 301"
self._state = None
self._available = True
self._attr_name = "PCA 301"
self._pca = pca
@property
def name(self):
"""Return the name of the Smart Plug, if any."""
return self._name
@property
def available(self) -> bool:
"""Return if switch is available."""
return self._available
@property
def is_on(self):
"""Return true if switch is on."""
return self._state
def turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
self._pca.turn_on(self._device_id)
@@ -85,10 +66,10 @@ class SmartPlugSwitch(SwitchEntity):
def update(self) -> None:
"""Update the PCA switch's state."""
try:
self._state = self._pca.get_state(self._device_id)
self._available = True
self._attr_is_on = self._pca.get_state(self._device_id)
self._attr_available = True
except OSError as ex:
if self._available:
if self._attr_available:
_LOGGER.warning("Could not read state for %s: %s", self.name, ex)
self._available = False
self._attr_available = False

View File

@@ -173,6 +173,9 @@ class GasSourceType(TypedDict):
stat_energy_from: str
# Instantaneous flow rate: m³/h, L/min, etc.
stat_rate: NotRequired[str]
# statistic_id of costs ($) incurred from the gas meter
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -190,6 +193,9 @@ class WaterSourceType(TypedDict):
stat_energy_from: str
# Instantaneous flow rate: L/min, gal/min, m³/h, etc.
stat_rate: NotRequired[str]
# statistic_id of costs ($) incurred from the water meter
# If set to None and entity_energy_price or number_energy_price are configured,
# an EnergyCostSensor will be automatically created
@@ -440,6 +446,7 @@ GAS_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
@@ -451,6 +458,7 @@ WATER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),

View File

@@ -44,6 +44,10 @@
"description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
},
"entity_unexpected_unit_volume_flow_rate": {
"description": "The following entities do not have an expected unit of measurement (either of {flow_rate_units}):",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"
},
"entity_unexpected_unit_water": {
"description": "The following entities do not have the expected unit of measurement (either of {water_units}):",
"title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]"

View File

@@ -14,6 +14,7 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -28,6 +29,11 @@ POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
VOLUME_FLOW_RATE_DEVICE_CLASSES = (sensor.SensorDeviceClass.VOLUME_FLOW_RATE,)
VOLUME_FLOW_RATE_UNITS: dict[str, tuple[UnitOfVolumeFlowRate, ...]] = {
sensor.SensorDeviceClass.VOLUME_FLOW_RATE: tuple(UnitOfVolumeFlowRate)
}
VOLUME_FLOW_RATE_UNIT_ERROR = "entity_unexpected_unit_volume_flow_rate"
ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
@@ -109,6 +115,12 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
return {
"price_units": ", ".join(f"{currency}{unit}" for unit in WATER_PRICE_UNITS),
}
if issue_type == VOLUME_FLOW_RATE_UNIT_ERROR:
return {
"flow_rate_units": ", ".join(
VOLUME_FLOW_RATE_UNITS[sensor.SensorDeviceClass.VOLUME_FLOW_RATE]
),
}
return None
@@ -590,6 +602,21 @@ def _validate_gas_source(
)
)
if stat_rate := source.get("stat_rate"):
wanted_statistics_metadata.add(stat_rate)
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
stat_rate,
VOLUME_FLOW_RATE_DEVICE_CLASSES,
VOLUME_FLOW_RATE_UNITS,
VOLUME_FLOW_RATE_UNIT_ERROR,
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
@@ -650,6 +677,21 @@ def _validate_water_source(
)
)
if stat_rate := source.get("stat_rate"):
wanted_statistics_metadata.add(stat_rate)
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
stat_rate,
VOLUME_FLOW_RATE_DEVICE_CLASSES,
VOLUME_FLOW_RATE_UNITS,
VOLUME_FLOW_RATE_UNIT_ERROR,
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""

View File

@@ -1,10 +1,12 @@
"""Support for EnOcean devices."""
from serial import SerialException
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -42,7 +44,10 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: EnOceanConfigEntry
) -> bool:
"""Set up an EnOcean dongle for the given entry."""
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
try:
usb_dongle = EnOceanDongle(hass, config_entry.data[CONF_DEVICE])
except SerialException as err:
raise ConfigEntryNotReady(f"Failed to set up EnOcean dongle: {err}") from err
await usb_dongle.async_setup()
config_entry.runtime_data = usb_dongle

View File

@@ -116,7 +116,7 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
return attr
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if sensor is on."""
return self._info["status"]["open"]

View File

@@ -77,7 +77,7 @@ class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return the boolean response if the zone is bypassed."""
return self._info["bypassed"]

View File

@@ -46,12 +46,12 @@ class EufyHomeLight(LightEntity):
self._temp = None
self._brightness = None
self._hs = None
self._state = None
self._name = device["name"]
self._address = device["address"]
self._code = device["code"]
self._attr_name = device["name"]
self._type = device["type"]
self._bulb = lakeside.bulb(self._address, self._code, self._type)
self._bulb = lakeside.bulb(
(device_address := device["address"]), device["code"], self._type
)
self._attr_unique_id = device_address
self._colormode = False
if self._type == "T1011":
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
@@ -72,22 +72,7 @@ class EufyHomeLight(LightEntity):
self._hs = color_util.color_RGB_to_hs(*self._bulb.colors)
else:
self._colormode = False
self._state = self._bulb.power
@property
def unique_id(self):
"""Return the ID of this light."""
return self._address
@property
def name(self):
"""Return the name of the device if any."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
return self._state
self._attr_is_on = self._bulb.power
@property
def brightness(self):

View File

@@ -30,33 +30,17 @@ class EufyHomeSwitch(SwitchEntity):
def __init__(self, device):
"""Initialize the light."""
self._state = None
self._name = device["name"]
self._address = device["address"]
self._code = device["code"]
self._type = device["type"]
self._switch = lakeside.switch(self._address, self._code, self._type)
self._attr_name = device["name"]
self._attr_unique_id = device["address"]
self._switch = lakeside.switch(
device["address"], device["code"], device["type"]
)
self._switch.connect()
def update(self) -> None:
"""Synchronise state from the switch."""
self._switch.update()
self._state = self._switch.power
@property
def unique_id(self):
"""Return the ID of this light."""
return self._address
@property
def name(self):
"""Return the name of the device if any."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
return self._state
self._attr_is_on = self._switch.power
def turn_on(self, **kwargs: Any) -> None:
"""Turn the specified switch on."""

View File

@@ -51,7 +51,7 @@ class FloPendingAlertsBinarySensor(FloEntity, BinarySensorEntity):
super().__init__("pending_system_alerts", device)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the Flo device has pending alerts."""
return self._device.has_alerts
@@ -78,6 +78,6 @@ class FloWaterDetectedBinarySensor(FloEntity, BinarySensorEntity):
super().__init__("water_detected", device)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the Flo device is detecting water."""
return self._device.water_detected

View File

@@ -223,7 +223,7 @@ class FluxSwitch(SwitchEntity, RestoreEntity):
return self._name
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if switch is on."""
return self.unsub_tracker is not None

View File

@@ -77,12 +77,10 @@ class FutureNowLight(LightEntity):
def __init__(self, device):
"""Initialize the light."""
self._name = device["name"]
self._attr_name = device["name"]
self._dimmable = device["dimmable"]
self._channel = device["channel"]
self._brightness = None
self._last_brightness = 255
self._state = None
if device["driver"] == CONF_DRIVER_FNIP6X10AD:
self._light = pyfnip.FNIP6x2adOutput(
@@ -93,21 +91,6 @@ class FutureNowLight(LightEntity):
device["host"], device["port"], self._channel
)
@property
def name(self):
"""Return the name of the device if any."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
return self._state
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
return self._brightness
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
@@ -131,11 +114,11 @@ class FutureNowLight(LightEntity):
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._light.turn_off()
if self._brightness:
self._last_brightness = self._brightness
if self._attr_brightness:
self._last_brightness = self._attr_brightness
def update(self) -> None:
"""Fetch new state data for this light."""
state = int(self._light.is_on())
self._state = bool(state)
self._brightness = to_hass_level(state)
self._attr_is_on = bool(state)
self._attr_brightness = to_hass_level(state)

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in Geofency and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
},
@@ -9,6 +10,10 @@
"default": "To send events to Home Assistant, you will need to set up the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
},
"step": {
"reconfigure": {
"description": "Are you sure you want to reconfigure the Geofency webhook?",
"title": "Reconfigure Geofency webhook"
},
"user": {
"description": "Are you sure you want to set up the Geofency webhook?",
"title": "Set up the Geofency webhook"

View File

@@ -23,8 +23,6 @@ ATTR_CREATED = "created"
ATTR_UPDATED = "updated"
ATTR_REMOVED = "removed"
DEFAULT_ICON = "mdi:pulse"
DEFAULT_UNIT_OF_MEASUREMENT = "quakes"
# An update of this entity is not making a web request, but uses internal data only.
PARALLEL_UPDATES = 0
@@ -45,19 +43,20 @@ async def async_setup_entry(
class GeonetnzQuakesSensor(SensorEntity):
"""Status sensor for the GeoNet NZ Quakes integration."""
_attr_icon = "mdi:pulse"
_attr_native_unit_of_measurement = "quakes"
_attr_should_poll = False
def __init__(self, config_entry_id, config_unique_id, config_title, manager):
"""Initialize entity."""
self._config_entry_id = config_entry_id
self._config_unique_id = config_unique_id
self._config_title = config_title
self._attr_unique_id = config_unique_id
self._attr_name = f"GeoNet NZ Quakes ({config_title})"
self._manager = manager
self._status = None
self._last_update = None
self._last_update_successful = None
self._last_timestamp = None
self._total = None
self._created = None
self._updated = None
self._removed = None
@@ -106,36 +105,11 @@ class GeonetnzQuakesSensor(SensorEntity):
else:
self._last_update_successful = None
self._last_timestamp = status_info.last_timestamp
self._total = status_info.total
self._attr_native_value = status_info.total
self._created = status_info.created
self._updated = status_info.updated
self._removed = status_info.removed
@property
def native_value(self):
"""Return the state of the sensor."""
return self._total
@property
def unique_id(self) -> str:
"""Return a unique ID containing latitude/longitude."""
return self._config_unique_id
@property
def name(self) -> str | None:
"""Return the name of the entity."""
return f"GeoNet NZ Quakes ({self._config_title})"
@property
def icon(self):
"""Return the icon to use in the frontend, if any."""
return DEFAULT_ICON
@property
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return DEFAULT_UNIT_OF_MEASUREMENT
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the device state attributes."""

View File

@@ -15,7 +15,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DISCOVERY_TIMEOUT
from .const import DISCOVERY_TIMEOUT, DOMAIN
from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
PLATFORMS: list[Platform] = [Platform.LIGHT]
@@ -52,7 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
_LOGGER.error("Start failed, errno: %d", ex.errno)
return False
_LOGGER.error("Port %s already in use", LISTENING_PORT)
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="port_in_use",
translation_placeholders={"port": LISTENING_PORT},
) from ex
await coordinator.async_config_entry_first_refresh()
@@ -61,7 +65,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoveeLocalConfigEntry) -
while not coordinator.devices:
await asyncio.sleep(delay=1)
except TimeoutError as ex:
raise ConfigEntryNotReady from ex
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="no_devices_found"
) from ex
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -33,5 +33,13 @@
}
}
}
},
"exceptions": {
"no_devices_found": {
"message": "[%key:common::config_flow::abort::no_devices_found%]"
},
"port_in_use": {
"message": "Port {port} is already in use"
}
}
}

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"cloud_not_connected": "[%key:common::config_flow::abort::cloud_not_connected%]",
"reconfigure_successful": "**Reconfiguration was successful**\n\nGo to the webhook feature in GPSLogger and update the webhook with the following settings:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details.",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
},
@@ -9,6 +10,10 @@
"default": "To send events to Home Assistant, you will need to set up the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
},
"step": {
"reconfigure": {
"description": "Are you sure you want to reconfigure the GPSLogger webhook?",
"title": "Reconfigure GPSLogger webhook"
},
"user": {
"description": "Are you sure you want to set up the GPSLogger webhook?",
"title": "Set up the GPSLogger webhook"

View File

@@ -74,18 +74,13 @@ class GreenwaveLight(LightEntity):
"""Initialize a Greenwave Reality Light."""
self._did = int(light["did"])
self._attr_name = light["name"]
self._state = int(light["state"])
self._attr_is_on = bool(int(light["state"]))
self._attr_brightness = greenwave.hass_brightness(light)
self._host = host
self._attr_available = greenwave.check_online(light)
self._token = token
self._gatewaydata = gatewaydata
@property
def is_on(self):
"""Return true if light is on."""
return self._state
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255) / 255) * 100)
@@ -101,7 +96,7 @@ class GreenwaveLight(LightEntity):
self._gatewaydata.update()
bulbs = self._gatewaydata.greenwave
self._state = int(bulbs[self._did]["state"])
self._attr_is_on = bool(int(bulbs[self._did]["state"]))
self._attr_brightness = greenwave.hass_brightness(bulbs[self._did])
self._attr_available = greenwave.check_online(bulbs[self._did])
self._attr_name = bulbs[self._did]["name"]

View File

@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
import growattServer
from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
@@ -54,6 +55,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_type = device_type
self.plant_id = plant_id
self.previous_values: dict[str, Any] = {}
self._pre_reset_values: dict[str, float] = {}
if self.api_version == "v1":
self.username = None
@@ -251,6 +253,40 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
return_value = previous_value
# Suppress midnight bounce for TOTAL_INCREASING "today" sensors.
# The Growatt API sometimes delivers stale yesterday values after a midnight
# reset (0 → stale → 0), causing TOTAL_INCREASING double-counting.
if (
entity_description.state_class is SensorStateClass.TOTAL_INCREASING
and not entity_description.never_resets
and return_value is not None
and previous_value is not None
):
current_val = float(return_value)
prev_val = float(previous_value)
if prev_val > 0 and current_val == 0:
# Value dropped to 0 from a positive level — track it.
self._pre_reset_values[variable] = prev_val
elif variable in self._pre_reset_values:
pre_reset = self._pre_reset_values[variable]
if current_val == pre_reset:
# Value equals yesterday's final value — the API is
# serving a stale cached response (bounce)
_LOGGER.debug(
"Suppressing midnight bounce for %s: stale value %s matches "
"pre-reset value, keeping %s",
variable,
current_val,
previous_value,
)
return_value = previous_value
elif current_val > 0:
# Genuine new-day production — clear tracking
del self._pre_reset_values[variable]
# Note: previous_values stores the *output* value (after suppression),
# not the raw API value. This is intentional — after a suppressed bounce,
# previous_value will be 0, which is what downstream comparisons need.
self.previous_values[variable] = return_value
return return_value

View File

@@ -152,6 +152,8 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity):
**kwargs: Any,
) -> None:
"""Install an update."""
self._attr_in_progress = True
self.async_write_ha_state()
await update_addon(
self.hass, self._addon_slug, backup, self.title, self.installed_version
)
@@ -308,6 +310,8 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
self._attr_in_progress = True
self.async_write_ha_state()
await update_core(self.hass, version, backup)
@callback

View File

@@ -7,6 +7,7 @@ from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
PLATFORMS = [
Platform.BUTTON,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,

View File

@@ -5,6 +5,14 @@
"default": "mdi:connection"
}
},
"number": {
"oled_fade": {
"default": "mdi:cellphone-information"
},
"reboot_timer": {
"default": "mdi:timer-refresh"
}
},
"select": {
"opmode": {
"default": "mdi:cogs"

View File

@@ -0,0 +1,101 @@
"""Number platform for HDFury Integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from hdfury import HDFuryAPI, HDFuryError
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFuryNumberEntityDescription(NumberEntityDescription):
"""Description for HDFury number entities."""
set_value_fn: Callable[[HDFuryAPI, str], Awaitable[None]]
NUMBERS: tuple[HDFuryNumberEntityDescription, ...] = (
HDFuryNumberEntityDescription(
key="oledfade",
translation_key="oled_fade",
mode=NumberMode.BOX,
native_min_value=1,
native_max_value=100,
native_step=1,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_oled_fade(value),
),
HDFuryNumberEntityDescription(
key="reboottimer",
translation_key="reboot_timer",
mode=NumberMode.BOX,
native_min_value=0,
native_max_value=100,
native_step=1,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.CONFIG,
set_value_fn=lambda client, value: client.set_reboot_timer(value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HDFuryConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers using the platform schema."""
coordinator = entry.runtime_data
async_add_entities(
HDFuryNumber(coordinator, description)
for description in NUMBERS
if description.key in coordinator.data.config
)
class HDFuryNumber(HDFuryEntity, NumberEntity):
"""Base HDFury Number Class."""
entity_description: HDFuryNumberEntityDescription
@property
def native_value(self) -> float:
"""Return the current number value."""
return float(self.coordinator.data.config[self.entity_description.key])
async def async_set_native_value(self, value: float) -> None:
"""Set Number Value Event."""
try:
await self.entity_description.set_value_fn(
self.coordinator.client, str(int(value))
)
except HDFuryError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
) from error
await self.coordinator.async_request_refresh()

View File

@@ -40,6 +40,14 @@
"name": "Issue hotplug"
}
},
"number": {
"oled_fade": {
"name": "OLED fade timer"
},
"reboot_timer": {
"name": "Restart timer"
}
},
"select": {
"opmode": {
"name": "Operation mode",

View File

@@ -19,8 +19,6 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
@@ -79,19 +77,9 @@ class HikvisionMotionSwitch(SwitchEntity):
def __init__(self, name, hikvision_cam):
"""Initialize the switch."""
self._name = name
self._attr_name = name
self._hikvision_cam = hikvision_cam
self._state = STATE_OFF
@property
def name(self):
"""Return the name of the device if any."""
return self._name
@property
def is_on(self):
"""Return true if device is on."""
return self._state == STATE_ON
self._attr_is_on = False
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
@@ -105,7 +93,5 @@ class HikvisionMotionSwitch(SwitchEntity):
def update(self) -> None:
"""Update Motion Detection state."""
enabled = self._hikvision_cam.is_motion_detection_enabled()
_LOGGING.info("enabled: %s", enabled)
self._state = STATE_ON if enabled else STATE_OFF
self._attr_is_on = self._hikvision_cam.is_motion_detection_enabled()
_LOGGING.info("enabled: %s", self._attr_is_on)

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
from homeassistant.core import HomeAssistant
@@ -105,6 +106,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
)
if config_entry.minor_version < 3:
# Set the state class to measurement for backward compatibility
options[CONF_STATE_CLASS] = SensorStateClass.MEASUREMENT
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=3
)
_LOGGER.debug(
"Migration to version %s.%s successful",

View File

@@ -9,6 +9,7 @@ from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -39,6 +40,7 @@ from .const import (
CONF_PERIOD_KEYS,
CONF_START,
CONF_TYPE_KEYS,
CONF_TYPE_RATIO,
CONF_TYPE_TIME,
DEFAULT_NAME,
DOMAIN,
@@ -101,10 +103,19 @@ async def get_state_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for options step."""
entity_id = handler.options[CONF_ENTITY_ID]
return _get_options_schema_with_entity_id(entity_id)
conf_type = handler.options[CONF_TYPE]
return _get_options_schema_with_entity_id(entity_id, conf_type)
def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
def _get_options_schema_with_entity_id(entity_id: str, type: str) -> vol.Schema:
state_class_options = (
[SensorStateClass.MEASUREMENT]
if type == CONF_TYPE_RATIO
else [
SensorStateClass.MEASUREMENT,
SensorStateClass.TOTAL_INCREASING,
]
)
return vol.Schema(
{
vol.Optional(CONF_ENTITY_ID): EntitySelector(
@@ -130,6 +141,13 @@ def _get_options_schema_with_entity_id(entity_id: str) -> vol.Schema:
vol.Optional(CONF_DURATION): DurationSelector(
DurationSelectorConfig(enable_day=True, allow_negative=False)
),
vol.Optional(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig(
options=state_class_options,
translation_key=CONF_STATE_CLASS,
mode=SelectSelectorMode.DROPDOWN,
),
),
}
)
@@ -158,7 +176,7 @@ OPTIONS_FLOW = {
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for History stats."""
MINOR_VERSION = 2
MINOR_VERSION = 3
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
@@ -201,6 +219,7 @@ async def ws_start_preview(
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
entity_id = options[CONF_ENTITY_ID]
name = options[CONF_NAME]
conf_type = options[CONF_TYPE]
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
@@ -208,6 +227,7 @@ async def ws_start_preview(
raise HomeAssistantError("Config entry not found")
entity_id = config_entry.options[CONF_ENTITY_ID]
name = config_entry.options[CONF_NAME]
conf_type = config_entry.options[CONF_TYPE]
@callback
def async_preview_updated(
@@ -233,7 +253,7 @@ async def ws_start_preview(
validated_data: Any = None
try:
validated_data = (_get_options_schema_with_entity_id(entity_id))(
validated_data = (_get_options_schema_with_entity_id(entity_id, conf_type))(
msg["user_input"]
)
except vol.Invalid as ex:
@@ -255,6 +275,7 @@ async def ws_start_preview(
start = validated_data.get(CONF_START)
end = validated_data.get(CONF_END)
duration = validated_data.get(CONF_DURATION)
state_class = validated_data.get(CONF_STATE_CLASS)
history_stats = HistoryStats(
hass,
@@ -274,6 +295,7 @@ async def ws_start_preview(
name=name,
unique_id=None,
source_entity_id=entity_id,
state_class=state_class,
)
preview_entity.hass = hass

View File

@@ -10,6 +10,7 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
@@ -72,6 +73,16 @@ def exactly_two_period_keys[_T: dict[str, Any]](conf: _T) -> _T:
return conf
def no_ratio_total[_T: dict[str, Any]](conf: _T) -> _T:
"""Ensure state_class:total_increasing not used with type:ratio."""
if (
conf.get(CONF_TYPE) == CONF_TYPE_RATIO
and conf.get(CONF_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING
):
raise vol.Invalid("State class total_increasing not to be used with type ratio")
return conf
PLATFORM_SCHEMA = vol.All(
SENSOR_PLATFORM_SCHEMA.extend(
{
@@ -83,9 +94,15 @@ PLATFORM_SCHEMA = vol.All(
vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Optional(
CONF_STATE_CLASS, default=SensorStateClass.MEASUREMENT
): vol.In(
[None, SensorStateClass.MEASUREMENT, SensorStateClass.TOTAL_INCREASING]
),
}
),
exactly_two_period_keys,
no_ratio_total,
)
@@ -106,6 +123,9 @@ async def async_setup_platform(
sensor_type: str = config[CONF_TYPE]
name: str = config[CONF_NAME]
unique_id: str | None = config.get(CONF_UNIQUE_ID)
state_class: SensorStateClass | None = config.get(
CONF_STATE_CLASS, SensorStateClass.MEASUREMENT
)
history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
@@ -121,6 +141,7 @@ async def async_setup_platform(
name=name,
unique_id=unique_id,
source_entity_id=entity_id,
state_class=state_class,
)
]
)
@@ -136,6 +157,7 @@ async def async_setup_entry(
sensor_type: str = entry.options[CONF_TYPE]
coordinator = entry.runtime_data
entity_id: str = entry.options[CONF_ENTITY_ID]
state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS)
async_add_entities(
[
HistoryStatsSensor(
@@ -145,6 +167,7 @@ async def async_setup_entry(
name=entry.title,
unique_id=entry.entry_id,
source_entity_id=entity_id,
state_class=state_class,
)
]
)
@@ -185,8 +208,6 @@ class HistoryStatsSensorBase(
class HistoryStatsSensor(HistoryStatsSensorBase):
"""A HistoryStats sensor."""
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
hass: HomeAssistant,
@@ -196,6 +217,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
name: str,
unique_id: str | None,
source_entity_id: str,
state_class: SensorStateClass | None,
) -> None:
"""Initialize the HistoryStats sensor."""
super().__init__(coordinator, name)
@@ -204,6 +226,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
) = None
self._attr_native_unit_of_measurement = UNITS[sensor_type]
self._type = sensor_type
self._attr_state_class = state_class
self._attr_unique_id = unique_id
if source_entity_id: # Guard against empty source_entity_id in preview mode
self.device_entry = async_entity_id_to_device(

View File

@@ -14,6 +14,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
"start": "Start",
"state": "[%key:component::history_stats::config::step::user::data::state%]",
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
"type": "[%key:component::history_stats::config::step::user::data::type%]"
},
"data_description": {
@@ -22,6 +23,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
"start": "When to start the measure (timestamp or datetime). Can be a template.",
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
"state_class": "The state class for statistics calculation.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "Read the documentation for further details on how to configure the history stats sensor using these options."
@@ -68,6 +70,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data::entity_id%]",
"start": "[%key:component::history_stats::config::step::options::data::start%]",
"state": "[%key:component::history_stats::config::step::user::data::state%]",
"state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]",
"type": "[%key:component::history_stats::config::step::user::data::type%]"
},
"data_description": {
@@ -76,6 +79,7 @@
"entity_id": "[%key:component::history_stats::config::step::user::data_description::entity_id%]",
"start": "[%key:component::history_stats::config::step::options::data_description::start%]",
"state": "[%key:component::history_stats::config::step::user::data_description::state%]",
"state_class": "The state class for statistics calculation. Changing the state class will require statistics to be reset.",
"type": "[%key:component::history_stats::config::step::user::data_description::type%]"
},
"description": "[%key:component::history_stats::config::step::options::description%]"
@@ -83,6 +87,12 @@
}
},
"selector": {
"state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"type": {
"options": {
"count": "Count",

View File

@@ -119,7 +119,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for item in data[API_TEMPERATURE][API_DATA]
if item[API_PLACE] == self.location
),
0,
None,
),
}

View File

@@ -6,13 +6,18 @@ import logging
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import EventKey
import aiohttp
import jwt
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
@@ -23,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
from .coordinator import HomeConnectConfigEntry, HomeConnectRuntimeData
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -71,19 +76,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
await coordinator.async_setup()
entry.runtime_data = coordinator
runtime_data = HomeConnectRuntimeData(hass, entry, home_connect_client)
await runtime_data.setup_appliance_coordinators()
entry.runtime_data = runtime_data
appliances_identifiers = {
(entry.domain, ha_id) for ha_id in entry.runtime_data.appliance_coordinators
}
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=entry.entry_id
)
for device in device_entries:
if not device.identifiers.intersection(appliances_identifiers):
device_registry.async_update_device(
device.id, remove_config_entry_id=entry.entry_id
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
for listener, context in runtime_data.global_listeners.values():
# We call the PAIRED event listener to start adding entities
# from the appliances we already found above
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
entry.runtime_data.start_event_listener()
entry.async_create_background_task(
hass,
coordinator.async_refresh(),
f"home_connect-initial-full-refresh-{entry.entry_id}",
)
for (
appliance_id,
appliance_coordinator,
) in entry.runtime_data.appliance_coordinators.items():
# We refresh each appliance coordinator in the background.
# to ensure that setup time is not impacted by this refresh.
entry.async_create_background_task(
hass,
appliance_coordinator.async_refresh(),
f"home_connect-initial-full-refresh-{entry.entry_id}-{appliance_id}",
)
return True
@@ -104,6 +136,9 @@ async def async_unload_entry(
]
for issue_id in issues_to_delete:
issue_registry.async_delete(DOMAIN, issue_id)
for coordinator in entry.runtime_data.appliance_coordinators.values():
await coordinator.async_shutdown()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
PARALLEL_UPDATES = 0
@@ -145,19 +145,18 @@ CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = [
HomeConnectConnectivityBinarySensor(
entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION
appliance_coordinator, CONNECTED_BINARY_ENTITY_DESCRIPTION
)
]
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
HomeConnectBinarySensor(appliance_coordinator, description)
for description in BINARY_SENSORS
if description.key in appliance.status
if description.key in appliance_coordinator.data.status
)
return entities

View File

@@ -10,11 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@@ -48,20 +44,18 @@ COMMAND_BUTTONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
appliance_data = appliance_coordinator.data
entities.extend(
HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
HomeConnectCommandButtonEntity(appliance_coordinator, description)
for description in COMMAND_BUTTONS
if description.key in appliance.commands
if description.key in appliance_data.commands
)
if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
entities.append(
HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
)
if appliance_data.info.type in APPLIANCES_WITH_PROGRAMS:
entities.append(HomeConnectStopProgramButtonEntity(appliance_coordinator))
return entities
@@ -87,17 +81,11 @@ class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: ButtonEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
desc,
(appliance.info.ha_id,),
)
super().__init__(appliance_coordinator, desc, context_override=True)
def update_native_value(self) -> None:
"""Set the value of the entity."""
@@ -130,15 +118,10 @@ class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
"""Button entity for stopping a program."""
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None:
def __init__(self, appliance_coordinator: HomeConnectApplianceCoordinator) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
appliance_coordinator,
ButtonEntityDescription(
key="StopProgram",
translation_key="stop_program",

View File

@@ -14,7 +14,11 @@ from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import (
HomeConnectApplianceCoordinator,
HomeConnectApplianceData,
HomeConnectConfigEntry,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
@@ -40,11 +44,10 @@ def should_add_option_entity(
def _create_option_entities(
entity_registry: er.EntityRegistry,
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
@@ -53,13 +56,13 @@ def _create_option_entities(
option_entities_to_add = [
entity
for entity in get_option_entities_for_appliance(
entry, appliance, entity_registry
appliance_coordinator, entity_registry
)
if entity.unique_id not in known_entity_unique_ids
]
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
cast(str, entity.unique_id): appliance_coordinator.data.info.ha_id
for entity in option_entities_to_add
}
)
@@ -71,10 +74,10 @@ def _handle_paired_or_connected_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
]
| None,
@@ -90,17 +93,18 @@ def _handle_paired_or_connected_appliance(
"""
entities: list[HomeConnectEntity] = []
entity_registry = er.async_get(hass)
for appliance in entry.runtime_data.data.values():
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values():
appliance_ha_id = appliance_coordinator.data.info.ha_id
entities_to_add = [
entity
for entity in get_entities_for_appliance(entry, appliance)
for entity in get_entities_for_appliance(appliance_coordinator)
if entity.unique_id not in known_entity_unique_ids
]
if get_option_entities_for_appliance:
entities_to_add.extend(
entity
for entity in get_option_entities_for_appliance(
entry, appliance, entity_registry
appliance_coordinator, entity_registry
)
if entity.unique_id not in known_entity_unique_ids
)
@@ -109,28 +113,24 @@ def _handle_paired_or_connected_appliance(
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
appliance_coordinator.async_add_listener(
partial(
_create_option_entities,
entity_registry,
entry,
appliance,
appliance_coordinator,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
event_key,
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callbacks[appliance_ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
{cast(str, entity.unique_id): appliance_ha_id for entity in entities_to_add}
)
entities.extend(entities_to_add)
async_add_entities(entities)
@@ -143,7 +143,7 @@ def _handle_depaired_appliance(
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
if appliance_id not in entry.runtime_data.appliance_coordinators:
known_entity_unique_ids.pop(entity_unique_id, None)
if appliance_id in changed_options_listener_remove_callbacks:
for listener in changed_options_listener_remove_callbacks.pop(
@@ -156,11 +156,11 @@ def setup_home_connect_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
[HomeConnectApplianceCoordinator], list[HomeConnectEntity]
],
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
]
| None = None,
@@ -172,7 +172,7 @@ def setup_home_connect_entry(
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
entry.runtime_data.async_add_global_listener(
partial(
_handle_paired_or_connected_appliance,
hass,
@@ -190,7 +190,7 @@ def setup_home_connect_entry(
)
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
entry.runtime_data.async_add_global_listener(
partial(
_handle_depaired_appliance,
entry,

View File

@@ -3,11 +3,9 @@
from __future__ import annotations
from asyncio import sleep as asyncio_sleep
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
@@ -33,7 +31,6 @@ from aiohomeconnect.model.error import (
UnauthorizedError,
)
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
@@ -54,7 +51,7 @@ _LOGGER = logging.getLogger(__name__)
MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour
MAX_EXECUTIONS = 8
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
type HomeConnectConfigEntry = ConfigEntry[HomeConnectRuntimeData]
@dataclass(frozen=True, kw_only=True)
@@ -96,12 +93,14 @@ class HomeConnectApplianceData:
)
class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
):
"""Class to manage fetching Home Connect data."""
class HomeConnectRuntimeData:
"""Class to manage Home Connect's integration runtime data.
It also handles the API server-sent events.
"""
config_entry: HomeConnectConfigEntry
appliance_coordinators: dict[str, HomeConnectApplianceCoordinator]
def __init__(
self,
@@ -110,64 +109,14 @@ class HomeConnectCoordinator(
client: HomeConnectClient,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.entry_id,
)
self.hass = hass
self.config_entry = config_entry
self.client = client
self._special_listeners: dict[
self.global_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
self.data = {}
self._execution_tracker: dict[str, list[float]] = defaultdict(list)
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
"""Return a dict of all listeners registered for a given context."""
listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list)
for listener, context in list(self._listeners.values()):
assert isinstance(context, tuple)
listeners[context].append(listener)
return listeners
@callback
def async_add_listener(
self, update_callback: CALLBACK_TYPE, context: Any = None
) -> Callable[[], None]:
"""Listen for data updates."""
remove_listener = super().async_add_listener(update_callback, context)
self.__dict__.pop("context_listeners", None)
def remove_listener_and_invalidate_context_listeners() -> None:
remove_listener()
self.__dict__.pop("context_listeners", None)
return remove_listener_and_invalidate_context_listeners
@callback
def async_add_special_listener(
self,
update_callback: CALLBACK_TYPE,
context: tuple[EventKey, ...],
) -> Callable[[], None]:
"""Listen for special data updates.
These listeners will not be called on refresh.
"""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._special_listeners.pop(remove_listener)
if not self._special_listeners:
self._unschedule_refresh()
self._special_listeners[remove_listener] = (update_callback, context)
return remove_listener
self.appliance_coordinators = {}
@callback
def start_event_listener(self) -> None:
@@ -178,7 +127,7 @@ class HomeConnectCoordinator(
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
)
async def _event_listener(self) -> None: # noqa: C901
async def _event_listener(self) -> None:
"""Match event with listener for event type."""
retry_time = 10
while True:
@@ -186,129 +135,37 @@ class HomeConnectCoordinator(
async for event_message in self.client.stream_all_events():
retry_time = 10
event_message_ha_id = event_message.ha_id
if (
event_message_ha_id in self.data
and not self.data[event_message_ha_id].info.connected
):
self.data[event_message_ha_id].info.connected = True
self._call_all_event_listeners_for_appliance(
event_message_ha_id
if event_message_ha_id in self.appliance_coordinators:
if event_message.type == EventType.DEPAIRED:
appliance_coordinator = self.appliance_coordinators.pop(
event_message.ha_id
)
await appliance_coordinator.async_shutdown()
else:
appliance_coordinator = self.appliance_coordinators[
event_message.ha_id
]
if not appliance_coordinator.data.info.connected:
appliance_coordinator.data.info.connected = True
appliance_coordinator.call_all_event_listeners()
elif event_message.type == EventType.PAIRED:
appliance_coordinator = HomeConnectApplianceCoordinator(
self.hass,
self.config_entry,
self.client,
self.global_listeners,
await self.client.get_specific_appliance(
event_message_ha_id
),
)
await appliance_coordinator.async_register_shutdown()
self.appliance_coordinators[event_message.ha_id] = (
appliance_coordinator
)
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message_ha_id].status
for event in event_message.data.items:
status_key = StatusKey(event.key)
if status_key in statuses:
statuses[status_key].value = event.value
else:
statuses[status_key] = Status(
key=status_key,
raw_key=status_key.value,
value=event.value,
)
if (
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
and event.value == BSH_OPERATION_STATE_PAUSE
and CommandKey.BSH_COMMON_RESUME_PROGRAM
not in (
commands := self.data[
event_message_ha_id
].commands
)
):
# All the appliances that can be paused
# should have the resume command available.
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
for (
listener,
context,
) in self._special_listeners.values():
if (
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
not in context
):
listener()
self._call_event_listener(event_message)
case EventType.NOTIFY:
settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
event_key = event.key
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
setting_key = SettingKey(event_key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
settings[setting_key] = GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
) and isinstance(event_value, str):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
events[event.key] = event
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
if self.refreshed_too_often_recently(event_message_ha_id):
continue
appliance_info = await self.client.get_specific_appliance(
event_message_ha_id
)
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
if event_message_ha_id not in self.data:
self.data[event_message_ha_id] = appliance_data
for listener, context in self._special_listeners.values():
if (
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
not in context
):
listener()
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
case EventType.DISCONNECTED:
self.data[event_message_ha_id].info.connected = False
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
case EventType.DEPAIRED:
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, event_message_ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.data.pop(event_message_ha_id, None)
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
assert appliance_coordinator
await appliance_coordinator.event_listener(event_message)
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug(
@@ -327,58 +184,27 @@ class HomeConnectCoordinator(
break
@callback
def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self.context_listeners.get(
(event_message.ha_id, event.key), []
):
listener()
def async_add_global_listener(
self,
update_callback: CALLBACK_TYPE,
context: tuple[EventKey, ...],
) -> Callable[[], None]:
"""Listen for special data updates.
@callback
def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None:
for listener, context in self._listeners.values():
if isinstance(context, tuple) and context[0] == ha_id:
listener()
These listeners will not be called on refresh.
"""
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect."""
await self._async_setup()
@callback
def remove_listener() -> None:
"""Remove update listener."""
self.global_listeners.pop(remove_listener)
for appliance_data in self.data.values():
appliance = appliance_data.info
ha_id = appliance.ha_id
while True:
try:
self.data[ha_id] = await self._get_appliance_data(
appliance, self.data.get(ha_id)
)
except TooManyRequestsError as err:
_LOGGER.debug(
"Rate limit exceeded on initial fetch: %s",
err,
)
await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
else:
break
self.global_listeners[remove_listener] = (update_callback, context)
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
return remove_listener
return self.data
async def async_setup(self) -> None:
"""Set up the devices."""
try:
await self._async_setup()
except UpdateFailed as err:
raise ConfigEntryNotReady from err
async def _async_setup(self) -> None:
"""Set up the devices."""
old_appliances = set(self.data.keys())
async def setup_appliance_coordinators(self) -> None:
"""Set up the coordinators for each appliance."""
try:
appliances = await self.client.get_home_appliances()
except UnauthorizedError as error:
@@ -388,9 +214,7 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
for appliance_data in self.data.values():
appliance_data.info.connected = False
raise UpdateFailed(
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
translation_placeholders=get_dict_from_home_connect_error(error),
@@ -404,52 +228,237 @@ class HomeConnectCoordinator(
name=appliance.name,
model=appliance.vib,
)
if appliance.ha_id not in self.data:
self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
else:
self.data[appliance.ha_id].info.connected = appliance.connected
old_appliances.remove(appliance.ha_id)
for ha_id in old_appliances:
self.data.pop(ha_id, None)
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, ha_id)}
new_coordinator = HomeConnectApplianceCoordinator(
self.hass,
self.config_entry,
self.client,
self.global_listeners,
appliance,
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
await new_coordinator.async_register_shutdown()
self.appliance_coordinators[appliance.ha_id] = new_coordinator
# Trigger to delete the possible depaired device entities
# from known_entities variable at common.py
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectApplianceData]):
"""Class to manage fetching Home Connect appliance data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: HomeConnectConfigEntry,
client: HomeConnectClient,
global_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
],
appliance: HomeAppliance,
) -> None:
"""Initialize."""
# Don't set config_entry attribute to avoid default behavior.
# HomeConnectApplianceCoordinator doesn't follow the
# config entry lifecycle so we can't use the default behavior.
self._config_entry = config_entry
super().__init__(
hass,
_LOGGER,
config_entry=None,
name=f"{self._config_entry.entry_id}-{appliance.ha_id}",
)
self.client = client
self.device_registry = dr.async_get(self.hass)
self.global_listeners = global_listeners
self.data = HomeConnectApplianceData.empty(appliance)
self._execution_tracker: list[float] = []
def _get_listeners_for_event_key(self, event_key: EventKey) -> list[CALLBACK_TYPE]:
return [
listener
for listener, context in list(self._listeners.values())
if context == event_key
]
async def event_listener(self, event_message: EventMessage) -> None:
"""Match event with listener for event type."""
match event_message.type:
case EventType.STATUS:
statuses = self.data.status
for event in event_message.data.items:
status_key = StatusKey(event.key)
if status_key in statuses:
statuses[status_key].value = event.value
else:
statuses[status_key] = Status(
key=status_key,
raw_key=status_key.value,
value=event.value,
)
if (
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
and event.value == BSH_OPERATION_STATE_PAUSE
and CommandKey.BSH_COMMON_RESUME_PROGRAM
not in (commands := self.data.commands)
):
# All the appliances that can be paused
# should have the resume command available.
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
for (
listener,
context,
) in self.global_listeners.values():
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
listener()
self._call_event_listener(event_message)
case EventType.NOTIFY:
settings = self.data.settings
events = self.data.events
for event in event_message.data.items:
event_key = event.key
if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
setting_key = SettingKey(event_key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
settings[setting_key] = GetSetting(
key=setting_key,
raw_key=setting_key.value,
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
) and isinstance(event_value, str):
await self.update_options(
event_key,
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
events = self.data.events
for event in event_message.data.items:
events[event.key] = event
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
if self.refreshed_too_often_recently():
return
await self.async_refresh()
for (
listener,
context,
) in self.global_listeners.values():
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
listener()
self.call_all_event_listeners()
case EventType.DISCONNECTED:
self.data.info.connected = False
self.call_all_event_listeners()
case EventType.DEPAIRED:
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, self.data.info.ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self._config_entry.entry_id,
)
for (
listener,
context,
) in self.global_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
@callback
def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self._get_listeners_for_event_key(event.key):
listener()
async def _get_appliance_data(
self,
appliance: HomeAppliance,
appliance_data_to_update: HomeConnectApplianceData | None = None,
) -> HomeConnectApplianceData:
@callback
def call_all_event_listeners(self) -> None:
"""Call all listeners."""
for listener, _ in self._listeners.values():
listener()
async def _async_update_data(self) -> HomeConnectApplianceData:
"""Fetch data from Home Connect."""
while True:
try:
try:
self.data.info.connected = (
await self.client.get_specific_appliance(self.data.info.ha_id)
).connected
except HomeConnectError:
self.data.info.connected = False
raise
await self.get_appliance_data()
except TooManyRequestsError as err:
delay = err.retry_after or API_DEFAULT_RETRY_AFTER
_LOGGER.warning(
"Rate limit exceeded, retrying in %s seconds: %s",
delay,
err,
)
await asyncio_sleep(delay)
except UnauthorizedError as error:
# Reauth flow need to be started explicitly as
# we don't use the default config entry coordinator.
self._config_entry.async_start_reauth(self.hass)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
else:
break
for (
listener,
context,
) in self.global_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
listener()
return self.data
async def get_appliance_data(self) -> None:
"""Get appliance data."""
appliance = self.data.info
self.device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
config_entry_id=self._config_entry.entry_id,
identifiers={(DOMAIN, appliance.ha_id)},
manufacturer=appliance.brand,
name=appliance.name,
model=appliance.vib,
)
if not appliance.connected:
_LOGGER.debug(
"Appliance %s is not connected, skipping data fetch",
appliance.ha_id,
self.data.update(HomeConnectApplianceData.empty(appliance))
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="appliance_disconnected",
translation_placeholders={
"appliance_name": appliance.name,
"ha_id": appliance.ha_id,
},
)
if appliance_data_to_update:
appliance_data_to_update.info.connected = False
return appliance_data_to_update
return HomeConnectApplianceData.empty(appliance)
try:
settings = {
setting.key: setting
@@ -521,9 +530,7 @@ class HomeConnectCoordinator(
current_program_key = program.key
program_options = program.options
if current_program_key:
options = await self.get_options_definitions(
appliance.ha_id, current_program_key
)
options = await self.get_options_definitions(current_program_key)
for option in program_options or []:
option_event_key = EventKey(option.key)
events[option_event_key] = Event(
@@ -550,23 +557,20 @@ class HomeConnectCoordinator(
except HomeConnectError:
commands = set()
appliance_data = HomeConnectApplianceData(
commands=commands,
events=events,
info=appliance,
options=options,
programs=programs,
settings=settings,
status=status,
self.data.update(
HomeConnectApplianceData(
commands=commands,
events=events,
info=appliance,
options=options,
programs=programs,
settings=settings,
status=status,
)
)
if appliance_data_to_update:
appliance_data_to_update.update(appliance_data)
appliance_data = appliance_data_to_update
return appliance_data
async def get_options_definitions(
self, ha_id: str, program_key: ProgramKey
self, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
if program_key is ProgramKey.UNKNOWN:
@@ -576,7 +580,7 @@ class HomeConnectCoordinator(
option.key: option
for option in (
await self.client.get_available_program(
ha_id, program_key=program_key
self.data.info.ha_id, program_key=program_key
)
).options
or []
@@ -586,20 +590,20 @@ class HomeConnectCoordinator(
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching options for %s: %s",
ha_id,
self.data.info.ha_id,
error,
)
return {}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
self, event_key: EventKey, program_key: ProgramKey
) -> None:
"""Update options for appliance."""
options = self.data[ha_id].options
events = self.data[ha_id].events
options = self.data.options
events = self.data.events
options_to_notify = options.copy()
options.clear()
options.update(await self.get_options_definitions(ha_id, program_key))
options.update(await self.get_options_definitions(program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None
@@ -617,21 +621,18 @@ class HomeConnectCoordinator(
)
options_to_notify.update(options)
for option_key in options_to_notify:
for listener in self.context_listeners.get(
(ha_id, EventKey(option_key)),
[],
):
for listener in self._get_listeners_for_event_key(EventKey(option_key)):
listener()
def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool:
def refreshed_too_often_recently(self) -> bool:
"""Check if the appliance data hasn't been refreshed too often recently."""
now = self.hass.loop.time()
execution_tracker = self._execution_tracker[appliance_ha_id]
execution_tracker = self._execution_tracker
initial_len = len(execution_tracker)
execution_tracker = self._execution_tracker[appliance_ha_id] = [
execution_tracker = self._execution_tracker = [
timestamp
for timestamp in execution_tracker
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
@@ -647,7 +648,7 @@ class HomeConnectCoordinator(
"and they will be enabled again whenever the connection stabilizes. "
"Consider trying to unplug the appliance "
"for a while to perform a soft reset",
self.data[appliance_ha_id].info.name,
self.data.info.name,
MAX_EXECUTIONS,
MAX_EXECUTIONS_TIME_WINDOW // 60,
)
@@ -656,7 +657,7 @@ class HomeConnectCoordinator(
_LOGGER.info(
'Connected/paired events from the appliance "%s" have stabilized,'
" updates have been re-enabled",
self.data[appliance_ha_id].info.name,
self.data.info.name,
)
return False

View File

@@ -47,8 +47,10 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
appliance.info.ha_id: await _generate_appliance_diagnostics(appliance)
for appliance in entry.runtime_data.data.values()
appliance_coordinator.data.info.ha_id: await _generate_appliance_diagnostics(
appliance_coordinator.data
)
for appliance_coordinator in entry.runtime_data.appliance_coordinators.values()
}
@@ -59,4 +61,6 @@ async def async_get_device_diagnostics(
ha_id = next(
(identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN),
)
return await _generate_appliance_diagnostics(entry.runtime_data.data[ha_id])
return await _generate_appliance_diagnostics(
entry.runtime_data.appliance_coordinators[ha_id].data
)

View File

@@ -22,34 +22,34 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .coordinator import HomeConnectApplianceCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
"""Generic Home Connect entity (base class)."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: EntityDescription,
context_override: Any | None = None,
) -> None:
"""Initialize the entity."""
context = (appliance.info.ha_id, EventKey(desc.key))
appliance_ha_id = appliance_coordinator.data.info.ha_id
context = EventKey(desc.key)
if context_override is not None:
context = context_override
super().__init__(coordinator, context)
self.appliance = appliance
super().__init__(appliance_coordinator, context)
self.appliance = appliance_coordinator.data
self.entity_description = desc
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
self._attr_unique_id = f"{appliance_ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.info.ha_id)},
identifiers={(DOMAIN, appliance_ha_id)},
)
self.update_native_value()

View File

@@ -22,11 +22,7 @@ from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@@ -78,14 +74,13 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectLight(entry.runtime_data, appliance, description)
HomeConnectLight(appliance_coordinator, description)
for description in LIGHTS
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
]
@@ -110,8 +105,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectLightEntityDescription,
) -> None:
"""Initialize the entity."""
@@ -119,7 +113,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
def get_setting_key_if_setting_exists(
setting_key: SettingKey | None,
) -> SettingKey | None:
if setting_key and setting_key in appliance.settings:
if setting_key and setting_key in appliance_coordinator.data.settings:
return setting_key
return None
@@ -134,7 +128,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
)
self._brightness_scale = desc.brightness_scale
super().__init__(coordinator, appliance, desc)
super().__init__(appliance_coordinator, desc)
match (self._brightness_key, self._custom_color_key):
case (None, None):
@@ -287,10 +281,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update,
(
self.appliance.info.ha_id,
EventKey(key),
),
EventKey(key),
)
)

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry, should_add_option_entity
from .const import DOMAIN, UNIT_MAP
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import get_dict_from_home_connect_error
@@ -123,28 +123,26 @@ NUMBER_OPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
HomeConnectNumberEntity(appliance_coordinator, description)
for description in NUMBERS
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
HomeConnectOptionNumberEntity(appliance_coordinator, description)
for description in NUMBER_OPTIONS
if should_add_option_entity(
description, appliance, entity_registry, Platform.NUMBER
description, appliance_coordinator.data, entity_registry, Platform.NUMBER
)
]

View File

@@ -41,11 +41,7 @@ from .const import (
VENTING_LEVEL_OPTIONS,
WARMING_LEVEL_OPTIONS,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
@@ -336,37 +332,37 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*(
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
HomeConnectProgramSelectEntity(appliance_coordinator, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.programs
if appliance_coordinator.data.programs
else []
),
*[
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
HomeConnectSelectEntity(appliance_coordinator, desc)
for desc in SELECT_ENTITY_DESCRIPTIONS
if desc.key in appliance.settings
if desc.key in appliance_coordinator.data.settings
],
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
HomeConnectSelectOptionEntity(appliance_coordinator, desc)
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT)
if should_add_option_entity(
desc, appliance_coordinator.data, entity_registry, Platform.SELECT
)
]
@@ -392,14 +388,12 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectProgramSelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)
self.set_options()
@@ -429,7 +423,7 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
self.async_on_remove(
self.coordinator.async_add_listener(
self.refresh_options,
(self.appliance.info.ha_id, EventKey.BSH_COMMON_APPLIANCE_CONNECTED),
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
)
)
@@ -470,15 +464,13 @@ class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key)
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)
@@ -547,15 +539,13 @@ class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key)
super().__init__(
coordinator,
appliance,
appliance_coordinator,
desc,
)

View File

@@ -26,7 +26,7 @@ from .const import (
BSH_OPERATION_STATE_RUN,
UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, constraint_fetcher
_LOGGER = logging.getLogger(__name__)
@@ -508,26 +508,26 @@ EVENT_SENSORS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*[
HomeConnectEventSensor(entry.runtime_data, appliance, description)
HomeConnectEventSensor(appliance_coordinator, description)
for description in EVENT_SENSORS
if description.appliance_types
and appliance.info.type in description.appliance_types
and appliance_coordinator.data.info.type in description.appliance_types
],
*[
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
HomeConnectProgramSensor(appliance_coordinator, desc)
for desc in BSH_PROGRAM_SENSORS
if desc.appliance_types and appliance.info.type in desc.appliance_types
if desc.appliance_types
and appliance_coordinator.data.info.type in desc.appliance_types
],
*[
HomeConnectSensor(entry.runtime_data, appliance, description)
HomeConnectSensor(appliance_coordinator, description)
for description in SENSORS
if description.key in appliance.status
if description.key in appliance_coordinator.data.status
],
]
@@ -607,7 +607,7 @@ class HomeConnectProgramSensor(HomeConnectSensor):
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_operation_state_event,
(self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE),
EventKey.BSH_COMMON_STATUS_OPERATION_STATE,
)
)

View File

@@ -1290,6 +1290,9 @@
}
},
"exceptions": {
"appliance_disconnected": {
"message": "Appliance {appliance_name} ({ha_id}) is disconnected"
},
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
},

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry, should_add_option_entity
from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error
@@ -170,36 +170,32 @@ SWITCH_OPTIONS = (
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
if SettingKey.BSH_COMMON_POWER_STATE in appliance_coordinator.data.settings:
entities.append(
HomeConnectPowerSwitch(
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
)
HomeConnectPowerSwitch(appliance_coordinator, POWER_SWITCH_DESCRIPTION)
)
entities.extend(
HomeConnectSwitch(entry.runtime_data, appliance, description)
HomeConnectSwitch(appliance_coordinator, description)
for description in SWITCHES
if description.key in appliance.settings
if description.key in appliance_coordinator.data.settings
)
return entities
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
HomeConnectSwitchOptionEntity(appliance_coordinator, description)
for description in SWITCH_OPTIONS
if should_add_option_entity(
description, appliance, entity_registry, Platform.SWITCH
description, appliance_coordinator.data, entity_registry, Platform.SWITCH
)
]

View File

@@ -27,6 +27,15 @@
"multiple_integration_config_errors": {
"message": "Failed to process config for integration {domain} due to multiple ({errors}) errors. Check the logs for more information."
},
"oauth2_helper_reauth_required": {
"message": "Credentials are invalid, re-authentication required"
},
"oauth2_helper_refresh_failed": {
"message": "OAuth2 token refresh failed for {domain}"
},
"oauth2_helper_refresh_transient": {
"message": "Temporary error refreshing credentials for {domain}, try again later"
},
"platform_component_load_err": {
"message": "Platform error: {domain} - {error}."
},

View File

@@ -22,6 +22,7 @@ from homematicip.device import (
PluggableDimmer,
SwitchMeasuring,
WiredDimmer3,
WiredPushButton,
)
from packaging.version import Version
@@ -93,6 +94,20 @@ async def async_setup_entry(
(Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer),
):
entities.append(HomematicipDimmer(hap, device))
elif isinstance(device, WiredPushButton):
optical_channels = sorted(
(
ch
for ch in device.functionalChannels
if ch.functionalChannelType
== FunctionalChannelType.OPTICAL_SIGNAL_CHANNEL
),
key=lambda ch: ch.index,
)
for led_number, ch in enumerate(optical_channels, start=1):
entities.append(
HomematicipOpticalSignalLight(hap, device, ch.index, led_number)
)
async_add_entities(entities)
@@ -421,3 +436,129 @@ def _convert_color(color: tuple) -> RGBColorState:
if 270 < hue <= 330:
return RGBColorState.PURPLE
return RGBColorState.RED
class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
"""Representation of HomematicIP WiredPushButton LED light."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
_attr_supported_features = LightEntityFeature.EFFECT
_attr_translation_key = "optical_signal_light"
_effect_to_behaviour: dict[str, OpticalSignalBehaviour] = {
"on": OpticalSignalBehaviour.ON,
"blinking": OpticalSignalBehaviour.BLINKING_MIDDLE,
"flash": OpticalSignalBehaviour.FLASH_MIDDLE,
"billow": OpticalSignalBehaviour.BILLOW_MIDDLE,
}
_behaviour_to_effect: dict[OpticalSignalBehaviour, str] = {
v: k for k, v in _effect_to_behaviour.items()
}
_attr_effect_list = list(_effect_to_behaviour)
_color_switcher: dict[str, tuple[float, float]] = {
RGBColorState.WHITE: (0.0, 0.0),
RGBColorState.RED: (0.0, 100.0),
RGBColorState.YELLOW: (60.0, 100.0),
RGBColorState.GREEN: (120.0, 100.0),
RGBColorState.TURQUOISE: (180.0, 100.0),
RGBColorState.BLUE: (240.0, 100.0),
RGBColorState.PURPLE: (300.0, 100.0),
}
def __init__(
self,
hap: HomematicipHAP,
device: WiredPushButton,
channel_index: int,
led_number: int,
) -> None:
"""Initialize the optical signal light entity."""
super().__init__(
hap,
device,
post=f"LED {led_number}",
channel=channel_index,
is_multi_channel=True,
channel_real_index=channel_index,
)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
channel = self.get_channel_or_raise()
return channel.on is True
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
channel = self.get_channel_or_raise()
return int((channel.dimLevel or 0.0) * 255)
@property
def hs_color(self) -> tuple[float, float]:
"""Return the hue and saturation color value [float, float]."""
channel = self.get_channel_or_raise()
simple_rgb_color = channel.simpleRGBColorState
return self._color_switcher.get(simple_rgb_color, (0.0, 0.0))
@property
def effect(self) -> str | None:
"""Return the current effect."""
channel = self.get_channel_or_raise()
return self._behaviour_to_effect.get(channel.opticalSignalBehaviour)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the optical signal light."""
state_attr = super().extra_state_attributes
channel = self.get_channel_or_raise()
if self.is_on:
state_attr[ATTR_COLOR_NAME] = channel.simpleRGBColorState
return state_attr
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
# Use hs_color from kwargs, if not applicable use current hs_color.
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
simple_rgb_color = _convert_color(hs_color)
# If no kwargs, use default value.
brightness = 255
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs[ATTR_BRIGHTNESS]
# Minimum brightness is 10, otherwise the LED is disabled
brightness = max(10, brightness)
dim_level = round(brightness / 255.0, 2)
effect = self.effect
if ATTR_EFFECT in kwargs:
effect = kwargs[ATTR_EFFECT]
elif effect is None:
effect = "on"
behaviour = self._effect_to_behaviour.get(effect, OpticalSignalBehaviour.ON)
await self._device.set_optical_signal_async(
channelIndex=self._channel,
opticalSignalBehaviour=behaviour,
rgb=simple_rgb_color,
dimLevel=dim_level,
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
channel = self.get_channel_or_raise()
simple_rgb_color = channel.simpleRGBColorState
await self._device.set_optical_signal_async(
channelIndex=self._channel,
opticalSignalBehaviour=OpticalSignalBehaviour.OFF,
rgb=simple_rgb_color,
dimLevel=0.0,
)

View File

@@ -28,6 +28,20 @@
}
},
"entity": {
"light": {
"optical_signal_light": {
"state_attributes": {
"effect": {
"state": {
"billow": "Billow",
"blinking": "Blinking",
"flash": "Flash",
"on": "[%key:common::state::on%]"
}
}
}
}
},
"sensor": {
"smoke_detector_alarm_counter": {
"name": "Alarm counter"

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