Compare commits

..

183 Commits

Author SHA1 Message Date
Paul Bottein 75ec9a9058 Publish numeric sensor device classes as generated sensor.json 2026-06-15 18:14:30 +02:00
Erik Montnemery 2434341e04 Queue nested firing of events (#173519) 2026-06-15 17:27:16 +02:00
Franck Nijhof 047edc035d Skip literal_eval for template results that cannot be a Python literal (#173664)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-15 17:24:29 +02:00
epenet 8b5f27e016 Optimize module parsing in pylint imports checker (#173077) 2026-06-15 17:12:02 +02:00
Markus Adrario 5200a8131f Homee: QS examples done (#173543) 2026-06-15 17:11:02 +02:00
Manu 2dc1870ecd Add notify entities to SMTP integration (#173557) 2026-06-15 16:32:24 +02:00
Josef Zweck d8f125dfe9 Add connectivity binary sensor to opendisplay (#172539) 2026-06-15 16:29:41 +02:00
Paulus Schoutsen 311cd56c93 Expose on-disk file path when resolving TTS media source (#172884)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 09:25:56 -05:00
Erwin Douna 4b17e3abcb MELCloud Home add diagnostics platform (#173583) 2026-06-15 16:24:03 +02:00
Martin Hjelmare f2839bbf7a Add util.dt.naive_now (#173443) 2026-06-15 16:22:42 +02:00
Mick Vleeshouwer 0229545184 Fix Atlantic DHW Production V2 CE FLAT C2 water heater controls in Overkiz (#172823) 2026-06-15 16:21:28 +02:00
bkobus-bbx e8ce995560 Add DHCP discovery support to BleBox integration (#173498) 2026-06-15 16:20:55 +02:00
johanzander 46ffb3bd95 Fix Growatt total_output_power 1000x too low with V1 API (#172474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 16:19:44 +02:00
Åke Strandberg 27677a07a6 Aqvify reaches silver tier on quality scale (#173618) 2026-06-15 16:12:44 +02:00
Tim Laing f619ccca4b Feature/icloud media browser (#162001)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2026-06-15 16:11:58 +02:00
Christian Lackas 09a72ac505 homematicip_cloud: harden post-reconnect state recovery using 2.9.0 diagnostics (#169526) 2026-06-15 16:08:40 +02:00
BrettLynch123 27573c5231 Fix daikin setup_error on transient DaikinException during startup (#173660) 2026-06-15 16:05:06 +02:00
Franck Nijhof d5f23fffa8 Bump pyvizio to 0.1.64 (#173859)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-15 16:04:18 +02:00
Paul van Schayck 3b70ac987d Migrate unifi_direct from DeviceScanner to ScannerEntity and add ConfigFlow (#171991) 2026-06-15 16:02:12 +02:00
epenet e00b8f154e Add pylint checker for direct calls to component.async_unload_entry (#173870) 2026-06-15 15:49:30 +02:00
Joost Lekkerkerker abc751fd1c Update agents to avoid useless comments (#173523)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-06-15 14:48:02 +01:00
Franck Nijhof 6b5c7ec864 Bump pyHik to 0.4.3 (#173889) 2026-06-15 15:47:48 +02:00
Jakub Brzezowski d63bb48040 Add integration for Greencell HabuDen EVSE (#145302) 2026-06-15 15:43:53 +02:00
Åke Strandberg b71b155ffb Automatic delete of stale devices for aqvify (#173496) 2026-06-15 15:39:33 +02:00
Franck Nijhof 0f59a6070f Include spoken language in Google Generative AI STT prompt (#173631) 2026-06-15 15:39:15 +02:00
Robert Resch bb34887983 Fix aw check requirements (#173893) 2026-06-15 15:35:49 +02:00
Paul Bottein 6a06873527 Add binary sensors to Yoto (#173612) 2026-06-15 15:33:53 +02:00
epenet c012acc685 Move incorrect test from manual to demo integration (#173888) 2026-06-15 15:25:12 +02:00
Robert Resch 735ef5fc14 Add head SHA tracking to requirements check workflow (#173874) 2026-06-15 15:24:02 +02:00
epenet 405b9db101 Refactor energyid tests to avoid direct call to async_unload_entry (#173885) 2026-06-15 15:21:10 +02:00
Matt Brown 57aede0e27 Include host address in broadlink setup timeout/error messages (#173661) 2026-06-15 15:18:51 +02:00
Franck Nijhof c9d7d842ff Avoid walking script variable ChainMap twice when tracing (#173665) 2026-06-15 15:18:28 +02:00
Manu 9e8af2d098 Remove MS Teams integration (#173643) 2026-06-15 15:14:18 +02:00
EnjoyingM 90dc3717b0 Bump wolflink to 0.0.52 (#173884) 2026-06-15 15:10:05 +02:00
Paul Bottein a7c70d4d26 Add sensors to Yoto (#173292) 2026-06-15 15:05:39 +02:00
Joakim Plate 1dc5f1b768 Abort gardena discovery before product detection if address is known (#173799) 2026-06-15 14:54:54 +02:00
wollew e9f4bea715 Refactor polling rain sensor to use coordinator in Velux integration (#168991)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-06-15 14:54:22 +02:00
Neffez f2aa8aa73d Support WiZ lights with unadvertised dual head ratio (#172854) 2026-06-15 14:36:20 +02:00
Rasmus Graham 6f0831ebbb Improve Verisure session handling and reauth flow (#171317) 2026-06-15 14:35:17 +02:00
Franck Nijhof 579fbd2ae8 Add pylint checker for unnecessary format_mac in connection tuples (#173729) 2026-06-15 14:33:23 +02:00
AlCalzone e056c7d78c Deprecate openSenseMap air quality entity (#173862)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:33:05 +02:00
renovate[bot] 956dbb8757 Update pytest-aiohttp to 1.1.1 (#173828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 14:27:48 +02:00
Franck Nijhof d1ce14db17 Bump pynintendoparental to 2.4.0.1 (#173871) 2026-06-15 14:25:26 +02:00
epenet b773973ab6 Refactor inels load/unload tests (#173873) 2026-06-15 14:16:50 +02:00
Franck Nijhof c28f596740 Raise HomeAssistantError in Modern Forms action handlers (#173727) 2026-06-15 14:13:16 +02:00
Thomas D 878970a0a7 Support multi-color lights for Qbus integration (#173730) 2026-06-15 14:06:48 +02:00
some-random-climber 998746889a Use hass.config_entries.async_setup in tibber tests (#173867) 2026-06-15 14:05:05 +02:00
some-random-climber d71eb19a64 Use hass.config_entries.async_setup in vesync tests (#173868) 2026-06-15 14:04:52 +02:00
bdlcalvin 3b46bf45e7 Add oven stop button to Whirlpool (#173413) 2026-06-15 14:04:30 +02:00
starkillerOG 79d7b96b8d Use HA webhook instead of reolink_aio webhook (#173840)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-15 14:04:06 +02:00
some-random-climber 479db477e9 Use hass.config_entries.async_setup in emulated_roku tests (#173865) 2026-06-15 14:03:28 +02:00
epenet 28430a660d Speedup test_send_video in telegram_bot tests (#173861) 2026-06-15 13:57:59 +02:00
Erik Montnemery f0d5d1d526 Add tests showing races in entity triggers (#173492)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-06-15 12:58:03 +02:00
Franck Nijhof 2fd8222465 Bump fyta_cli to 0.7.3 (#173784) 2026-06-15 12:53:42 +02:00
Franck Nijhof c36dd62e2e Bump guppy3 to 3.1.7 (#173844) 2026-06-15 13:38:47 +03:00
vemboy20 2c3d629edb Update documentation status for Roborock component (#173486) 2026-06-15 12:28:55 +02:00
Brett Adams 72d72ba428 Add Powerwall 3 support to powerwall integration (#169137)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-15 12:20:13 +02:00
Franck Nijhof f9b8f05403 Bump aiopnsense to 1.0.10 (#173843) 2026-06-15 12:09:23 +02:00
Erwin Douna 6dfe6472af MELCloud Home refactor error translations (#173852) 2026-06-15 12:08:31 +02:00
some-random-climber 5ac1dd8288 Use hass.config_entries.async_setup in telegram_bot tests (#173857) 2026-06-15 12:05:35 +02:00
some-random-climber 68b2a1326c Use hass.config_entries.async_setup in iometer tests (#173855) 2026-06-15 11:57:26 +02:00
some-random-climber b56d261e53 Use hass.config_entries.async_setup in inels tests (#173854) 2026-06-15 11:57:13 +02:00
Franck Nijhof f9f37d1c2c Bump boschshcpy to 0.2.111 (#173842) 2026-06-15 11:43:37 +02:00
Franck Nijhof 0dffc84280 Bump buienradar to 1.0.9 (#173848) 2026-06-15 11:27:07 +02:00
Petro31 2ffcc70544 Add brightness_pct to set_level action (#173285) 2026-06-15 12:19:55 +03:00
some-random-climber 3c4757d944 Migrate async_setup_entry test calls to hass.config_entries.async_setup (#173850) 2026-06-15 11:02:08 +02:00
some-random-climber 1f6c45ca7a Migrate async_setup_entry test calls to hass.config_entries.async_setup (#173849) 2026-06-15 10:49:26 +02:00
Assaf Inbal efb08dfd78 Bump pyituran to 0.1.6 (#173833) 2026-06-15 10:43:55 +02:00
Franck Nijhof 65b469e185 Bump fjaraskupan to 2.3.4 (#173841) 2026-06-15 10:37:00 +02:00
Erik Montnemery 00b004c329 Make zone.async_in_zones prioritize the smallest zone (#173106)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-06-15 10:24:19 +02:00
Franck Nijhof c51409758e Fix exception translation placeholder mismatch in Teslemetry (#173734) 2026-06-15 11:08:42 +03:00
Erwin Douna 9a3d4a0ecc Add initial Proxmox quality scale (#161140)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Tom <CoMPaTech@users.noreply.github.com>
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-15 11:04:04 +03:00
Franck Nijhof 8c94cfe124 Bump switchbot-api to 2.11.1 (#173795) 2026-06-15 10:59:17 +03:00
Franck Nijhof 8290bd2d8e Bump pyipp to 0.17.2 (#173797) 2026-06-15 10:58:46 +03:00
Franck Nijhof 24045c67d2 Bump zabbix-utils to 2.0.4 (#173839) 2026-06-15 09:57:34 +02:00
Franck Nijhof 9f30cb3c4f Bump datadog to 0.52.1 (#173838) 2026-06-15 09:53:35 +02:00
Franck Nijhof 12c3bb3482 Bump rflink to 0.0.68 (#173808)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-15 09:49:50 +02:00
Franck Nijhof c164652d03 Bump debugpy to 1.8.21 (#173826)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-15 09:39:34 +02:00
Franck Nijhof 6c483b46fb Bump pysnmp to 7.1.27 (#173791) 2026-06-15 09:29:46 +02:00
Franck Nijhof e86986bc83 Bump solax to 3.2.4 (#173787) 2026-06-15 09:17:30 +02:00
Franck Nijhof 0cffa7ba6b Bump tessie-api to 0.1.3 (#173803) 2026-06-15 09:01:15 +02:00
Franck Nijhof cff58aed7d Bump gTTS to 2.5.4 (#173836) 2026-06-15 08:58:40 +02:00
Franck Nijhof a4e89020cf Bump pyblu to 2.0.8 (#173789) 2026-06-15 08:27:09 +02:00
Franck Nijhof 143b340327 Bump aioqsw to 0.4.3 (#173802) 2026-06-15 08:26:12 +02:00
Ronald van der Meer 39fe1479e2 Fix BSCO2 sensors not being added in Duco (#173794) 2026-06-15 08:25:50 +02:00
Ronald van der Meer 07635debda Redact raw payload fields in Duco diagnostics (#173790) 2026-06-15 08:25:19 +02:00
Franck Nijhof 6313450ec1 Bump pyomie to 1.1.3 (#173824) 2026-06-15 08:24:03 +02:00
NANI 6e3643ebc2 Bump victron-vrm to 0.1.12 (#173821) 2026-06-15 08:23:42 +02:00
Franck Nijhof 5e2af44e05 Bump goslide-api to 0.7.4 (#173823) 2026-06-15 08:23:01 +02:00
Franck Nijhof 6fab59c9d2 Bump openwrt-ubus-rpc to 0.0.3 (#173810) 2026-06-15 08:20:29 +02:00
Franck Nijhof e0041c7361 Bump roborock dependencies (#173766) 2026-06-15 08:18:54 +02:00
renovate[bot] 9e2ac3e5ca Update rf-protocols to 4.3.0 (#173829) 2026-06-15 06:57:05 +02:00
Franck Nijhof 95f261db66 Bump serialx to 1.8.2 (#173817) 2026-06-15 06:27:23 +02:00
Franck Nijhof c576d5267a Bump steamloop to 1.2.1 (#173809) 2026-06-14 19:45:34 -05:00
Franck Nijhof 1241a11c9d Bump bleak-esphome to 3.9.4 (#173825) 2026-06-14 17:56:47 -05:00
Franck Nijhof 8f7447de58 Bump aiocomelit to 2.0.5 (#173800) 2026-06-15 00:00:16 +02:00
Franck Nijhof 1e54dba835 Bump pyiskra to 0.1.29 (#173820) 2026-06-15 00:00:07 +02:00
Manu 20583d6d1b Bump pyrate-limiter to 4.4.0 (#173819) 2026-06-14 23:59:57 +02:00
Franck Nijhof b94370ee51 Bump devolo-home-control-api to 0.19.1 (#173806) 2026-06-14 23:59:51 +02:00
Franck Nijhof d068a2aa11 Bump blinkpy to 0.25.6 (#173811) 2026-06-14 23:59:30 +02:00
Franck Nijhof 5d53a1f204 Bump anthemav to 1.4.2 (#173812) 2026-06-14 23:59:08 +02:00
Franck Nijhof 2908f37130 Bump pyimouapi to 1.2.8 (#173813) 2026-06-14 23:58:45 +02:00
Franck Nijhof a88a795ad3 Bump bluecurrent-api to 1.3.3 (#173815) 2026-06-14 23:58:24 +02:00
Franck Nijhof 73a36a2c47 Bump denonavr to 1.3.3 (#173814) 2026-06-14 23:58:05 +02:00
Franck Nijhof 054494181e Bump geniushub-client to 0.7.4 (#173818) 2026-06-14 23:57:38 +02:00
Franck Nijhof e4e8f901ab Bump yalexs-ble to 3.3.1 (#173792) 2026-06-14 14:58:12 -05:00
Franck Nijhof b7a29bfa2f Bump pyrainbird to 6.3.1 (#173786) 2026-06-14 21:23:59 +02:00
Franck Nijhof 26b0079945 Bump pyenphase to 2.4.9 (#173785) 2026-06-14 14:13:54 -05:00
Raphael Hehl 7454f40dd8 Bump uiprotect to 13.1.2 (#173728) 2026-06-14 19:12:07 +02:00
Franck Nijhof 26b7d1e32c Slugify OwnTracks beacon name in entity ID (#173629) 2026-06-14 19:12:05 +02:00
Franck Nijhof f7342ea9b0 Add missing translation_domain to nasweb exception raises (#173732) 2026-06-14 19:11:42 +02:00
Franck Nijhof 825d99ddaf Bump uv to 0.11.21 (#173768) 2026-06-14 19:09:43 +02:00
Michael 401fae6bdd Bump py-synologydsm-api to 2.10.0 (#173774) 2026-06-14 19:08:49 +02:00
Franck Nijhof 5433beeec1 Skip Miele fan set_percentage when already at the target step (#173725) 2026-06-14 19:06:54 +02:00
G Johansson af60e248d3 Remove listener from holiday calendar when entity is disabled (#173759) 2026-06-14 19:05:33 +02:00
Franck Nijhof 8c452c280f Add missing flow form field translation in hue (#173747) 2026-06-14 19:02:44 +02:00
Franck Nijhof 3aec970321 Add missing flow form field translations in islamic_prayer_times (#173749) 2026-06-14 19:02:03 +02:00
Franck Nijhof 687c91d5f4 Add missing flow form field translation in lacrosse_view (#173750) 2026-06-14 19:01:36 +02:00
Franck Nijhof 377fdceb6c Add missing flow form field translation in melnor (#173752) 2026-06-14 19:00:56 +02:00
Franck Nijhof 11a4533ccc Fix flow form field translation key in meteoclimatic (#173754) 2026-06-14 19:00:12 +02:00
Franck Nijhof 52b2738b2a Add missing flow form field translation in motionblinds_ble (#173758) 2026-06-14 18:59:23 +02:00
Franck Nijhof 3fda722dbb Add missing flow form field translation in blink (#173756) 2026-06-14 18:58:52 +02:00
Franck Nijhof e01215da0e Fix exception translation placeholder mismatch in Swiss Public Transport (#173735) 2026-06-14 18:58:05 +02:00
starkillerOG 6c116cf3e4 Bump reolink_aio to 0.21.1 (#173772) 2026-06-14 18:56:55 +02:00
Franck Nijhof 8017e802dd Bump aiopulse to 0.4.7 (#173763) 2026-06-14 18:56:12 +02:00
Franck Nijhof 501d956b1b Bump pylint to 4.0.6 (#173769) 2026-06-14 18:52:24 +02:00
Franck Nijhof 8aca342a78 Bump snapcast to 2.3.8 (#173765) 2026-06-14 18:51:48 +02:00
Franck Nijhof bd68e9fbe3 Bump syrupy to 5.3.2 (#173767) 2026-06-14 18:51:12 +02:00
Franck Nijhof b75c839868 Bump python-linkplay to 0.2.14 (#173770) 2026-06-14 18:49:59 +02:00
Franck Nijhof 742bfb00ff Bump anova-wifi to 0.17.1 (#173764) 2026-06-14 18:49:24 +02:00
Franck Nijhof 987c19d991 Replace duplicate SERVICE_RELOAD constant with homeassistant.const import in conversation (#173741) 2026-06-14 18:48:55 +02:00
Franck Nijhof b4319c4d0c Bump aioacaia to 0.1.18 (#173762) 2026-06-14 18:35:35 +02:00
Franck Nijhof 0fdb3ebed7 Bump aioamazondevices to 14.0.4 (#173761) 2026-06-14 18:16:06 +02:00
G Johansson efa3334616 Bump lxml to 6.1.1 (#173748) 2026-06-14 18:00:43 +02:00
Franck Nijhof 9ec0f2fe4f Add missing flow form field translation in gogogate2 (#173753) 2026-06-14 17:30:29 +02:00
Franck Nijhof 9bc5e2b06b Fix flow form field translation key in lookin (#173751) 2026-06-14 17:30:21 +02:00
Franck Nijhof 46a38cc481 Add missing flow form field translation in flux_led (#173746) 2026-06-14 17:30:13 +02:00
Franck Nijhof a63f2f1d20 Replace duplicate constants with homeassistant.const imports in Teslemetry (#173744) 2026-06-14 17:26:39 +02:00
Franck Nijhof 744bb6a068 Add missing flow form field translation in tuya (#173745) 2026-06-14 17:26:16 +02:00
Franck Nijhof d449e3e97b Replace duplicate constants with homeassistant.const imports in select (#173743) 2026-06-14 17:25:53 +02:00
Franck Nijhof 0df379704f Replace duplicate CONF_OPTIONS constant with homeassistant.const import in input_select (#173742) 2026-06-14 17:25:24 +02:00
Franck Nijhof 4ab7ce04a8 Add missing exception translation key in Israel Rail (#173738) 2026-06-14 17:24:54 +02:00
Franck Nijhof 210b08b637 Fix exception translation placeholder mismatch in Homevolt (#173737) 2026-06-14 17:24:31 +02:00
Franck Nijhof f0b448dc6e Fix exception translation placeholder mismatch in Snoo (#173736) 2026-06-14 17:24:04 +02:00
Franck Nijhof b5a314bf60 Add missing exception translation keys in VeSync (#173739) 2026-06-14 17:23:38 +02:00
Franck Nijhof 741c342749 Replace duplicate CONF_EVENT constant with homeassistant.const import in calendar (#173740) 2026-06-14 17:23:11 +02:00
Franck Nijhof f4d4df9c35 Fix options flow form field translation key in plaato (#173755) 2026-06-14 17:22:07 +02:00
Franck Nijhof bcbdf7b2bb Add missing flow form field translation in snooz (#173760) 2026-06-14 17:21:15 +02:00
iluebbe b3309ef169 Add Powerline hint to username field description (#167473)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-14 16:57:21 +02:00
epenet caaf5f9715 Adjust pylint checker to prevent invalid use of Platform enum (#173374)
Co-authored-by: Markus Tuominen <3738613+Markus98@users.noreply.github.com>
2026-06-14 16:17:00 +02:00
BrettLynch123 7ce7de3650 Fix tessie setup_error on transient aiohttp.ClientError during startup (#173659) 2026-06-14 15:49:22 +02:00
fdebrus 2c14c6be75 Optimistic UI updates for Vistapool write entities (#173373)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-14 15:41:57 +02:00
Christian Lackas e020f338ab Add window state sensor for HomematicIP rotary handle (HmIP-SRH) (#173423) 2026-06-14 15:36:15 +02:00
jasonjhofmann c85c2c4cd3 Add network MAC connection to JVC Projector device (#173683)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:56:29 +02:00
Raman Gupta c4e618e990 Stop validating # of slots in zwave_js.set_credential action (#173644) 2026-06-14 14:18:32 +02:00
Åke Strandberg 5efde60d21 Remove unnecessary #pylint disable..." (#173726) 2026-06-14 14:17:16 +02:00
jasonjhofmann d9dc10ed81 Add network MAC connection to myStrom bulb devices (#173707)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:16:50 +02:00
Onero-testdev cb6ae03d21 Register SwitchBot Standing Fan device (#173577)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:41:17 +02:00
Allen Porter 915b78473c Improve Rainbird config flow test coverage (#173703) 2026-06-14 13:35:54 +02:00
Vincent Wolsink 559006ba19 Adjust humidity attributes to (mandatory) new controller firmware in Huum (#173702) 2026-06-14 13:22:35 +02:00
Thomas D bad2eed9fe Bump qbusmqttapi to v1.5.1 for the Qbus integration (#173714) 2026-06-14 13:14:58 +02:00
Sid 9f1a079688 Bump eheimdigital to 1.7.0 (#173716) 2026-06-14 13:00:52 +02:00
Bipin Kumar 965a96b957 Add Dry and Fan-Only modes to Panasonic CS-CU-EZ18CKYXFM AC in Matter (#173709) 2026-06-14 12:40:37 +02:00
Manu d5791ae8b4 Remove cleanup code for removed entities from Xbox integration (#173688) 2026-06-14 10:51:33 +02:00
J. Nick Koston 7b561934ea Bump aiodiscover to 3.3.2 (#173705) 2026-06-13 22:44:47 -05:00
Franck Nijhof cf60690fb7 Skip building ZHA entity log messages when the level is disabled (#173695) 2026-06-13 22:15:47 +02:00
jasonjhofmann 34d175e452 Add Bluetooth connection to Melnor devices (#173669)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:27:19 +02:00
jasonjhofmann 88f1cb55d4 Add Bluetooth connection to Snooz devices (#173668)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:26:55 +02:00
jasonjhofmann 2972d9eaa5 Add Bluetooth connection to Eurotronic Comet Blue devices (#173670)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:24:02 +02:00
Paul Bottein a9de180937 Bump yoto-api to 4.2.1 (#173699) 2026-06-13 20:23:46 +02:00
Paul Bottein 7a898c0eca Add time platform to Yoto (#173617) 2026-06-13 20:23:09 +02:00
A. Gideonse d3d883358c Add optimistic updates for Indevolt (#173091) 2026-06-13 20:20:37 +02:00
karwosts 483f7072dd Add missing template device class translations (#173121) 2026-06-13 18:18:49 +02:00
James Myatt 2db3a5024b Clean up local todo doc strings, locking, and test style (#173461) 2026-06-13 08:43:33 -07:00
Franck Nijhof 0b870e104f Avoid slicing MQTT payload for debug log on every received message (#173693) 2026-06-13 16:24:16 +02:00
Åke Strandberg c5acc04860 Add missing Miele dishwasher codes (#173662) 2026-06-13 14:53:54 +02:00
epenet a1486af33a Add ext_temp as datapoint for Tuya wsdcg category (#173366) 2026-06-13 14:45:00 +02:00
jasonjhofmann 527c0b1fb8 Add network MAC connection to Ring devices (#173671)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:13:05 -04:00
Franck Nijhof d284dff5ce Precompile entity service schemas to avoid per-call recompilation (#173685) 2026-06-13 08:11:11 -04:00
Martin Hoefling 3fbdb88b3c Migrate to knx telegram store (#169700)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Matthias Alphart <farmio@alphart.net>
2026-06-13 07:43:48 -04:00
Franck Nijhof 9957393f91 Fix workday entity triggering updates while disabled (#173626) 2026-06-13 13:39:02 +02:00
Raphael Hehl 95e6c39e40 Bump uiprotect to 13.1.1 (#173584) 2026-06-13 09:52:11 +02:00
renovate[bot] 54b6c5c542 Update rf-protocols to 4.2.0 (#173650) 2026-06-13 09:50:22 +02:00
491 changed files with 17140 additions and 2390 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
# GitHub Copilot & Claude Code Instructions
@@ -50,4 +51,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
@@ -50,19 +50,24 @@ jobs:
check-latest: true
- name: Install script dependencies
run: pip install -r script/check_requirements/requirements.txt
- name: Collect PR diff
- name: Collect PR diff and head SHA
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
mkdir -p deterministic
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
HEAD_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')
echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}"
- name: Run deterministic checks
env:
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
python -m script.check_requirements \
--pr-number "${PR_NUMBER}" \
--head-sha "${HEAD_SHA}" \
--diff deterministic/pr.diff \
--output deterministic/results.json
- name: Upload deterministic-results artifact
+76 -3
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"328ce915f8b178bbcfcb1b69f397ac996456a4ab50490805b5b3bdd26cbf58fe","body_hash":"0665c72fe4b0a14b3a0b396dc293ce78235771fe15ebc894662fece33b189135","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
@@ -345,6 +345,7 @@ jobs:
needs:
- activation
- extract_pr_number
- gate
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
runs-on: ubuntu-latest
permissions:
@@ -994,6 +995,7 @@ jobs:
- agent
- detection
- extract_pr_number
- gate
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
@@ -1428,8 +1430,8 @@ jobs:
}
extract_pr_number:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -1460,6 +1462,77 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
gate:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
- name: Decide whether requirements changed since the last comment
id: gate
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pre_activation:
runs-on: ubuntu-slim
outputs:
+65 -3
View File
@@ -22,9 +22,69 @@ safe-outputs:
needs:
- extract_pr_number
jobs:
extract_pr_number:
gate:
# Skip the (token-spending) agent when no tracked requirement file changed
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Decide whether requirements changed since the last comment
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check comment.
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oE '<!-- requirements-check-sha: [0-9a-f]{7,40} -->' \
| grep -oE '[0-9a-f]{7,40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
@@ -128,8 +188,10 @@ Then stop. Do not improvise a verdict.
Replace every placeholder with the resolved value and emit
`rendered_comment` via `add_comment`. Preserve the leading
`<!-- requirements-check -->` marker. The PR target is already wired;
do not pass `item_number`.
`<!-- requirements-check -->` marker and the
`<!-- requirements-check-sha: … -->` marker that follows it — the next
run reads the recorded commit from it to decide whether anything changed.
The PR target is already wired; do not pass `item_number`.
## Check instructions
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+2 -1
View File
@@ -40,4 +40,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
Generated
+3 -1
View File
@@ -695,6 +695,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
/tests/components/greencell/ @BrzezowskiGC
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -1155,7 +1157,6 @@ CLAUDE.md @home-assistant/core
/tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys
@@ -1891,6 +1892,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/tests/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
-1
View File
@@ -11,7 +11,6 @@
"microsoft_face_identify",
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
+1 -1
View File
@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.17"]
"requirements": ["aioacaia==0.1.18"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.8.0"]
"requirements": ["serialx==1.8.2"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/acmeda",
"iot_class": "local_push",
"loggers": ["aiopulse"],
"requirements": ["aiopulse==0.4.6"]
"requirements": ["aiopulse==0.4.7"]
}
@@ -1,10 +1,6 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,7 +28,7 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
connections={
(
CONNECTION_NETWORK_MAC,
format_mac(self.coordinator.data["status"]["mac_address"]),
self.coordinator.data["status"]["mac_address"],
)
},
manufacturer="AirVisual",
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==14.0.3"]
"requirements": ["aioamazondevices==14.0.4"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["anova_wifi"],
"requirements": ["anova-wifi==0.17.0"]
"requirements": ["anova-wifi==0.17.1"]
}
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["anthemav"],
"requirements": ["anthemav==1.4.1"]
"requirements": ["anthemav==1.4.2"]
}
+19 -2
View File
@@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -49,6 +50,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
self.previous_devices: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -102,10 +104,25 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
},
) from err
current_devices = set(devices.devices.keys())
if stale_devices := self.previous_devices - current_devices:
account_id = self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
for device_id in stale_devices:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
device_data = {}
for device in devices.devices.values():
for aqvify_device in devices.devices.values():
try:
device_key = str(device.device_key)
device_key = str(aqvify_device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.9"]
}
@@ -29,16 +29,28 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
docs-configuration-parameters:
status: exempt
comment: |
There are no configuration options.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: |
Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: todo
@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.7", "yalexs-ble==3.3.1"]
}
+43 -23
View File
@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
@@ -75,6 +76,21 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_box_from_host_or_abort(
self, api_host: ApiHost
) -> Box | ConfigFlowResult:
"""Try to connect to the device; return product or an abort result."""
try:
return await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except Error:
return self.async_abort(reason="cannot_connect")
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
@@ -101,45 +117,50 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port
websession = async_get_clientsession(hass)
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
"""Handle discovery by IP and port; probe device then confirm with the user."""
self.device_config["host"] = host
self.device_config["port"] = port
websession = async_get_clientsession(self.hass)
api_host = ApiHost(
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
result = await self._async_box_from_host_or_abort(api_host)
if not isinstance(result, Box):
return result
product = result
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": self.device_config["host"],
"host": host,
},
"configuration_url": f"http://{discovery_info.host}",
"configuration_url": f"http://{host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_handle_discovery(
discovery_info.host, discovery_info.port or DEFAULT_PORT
)
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -158,7 +179,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={
"name": self.device_config["name"],
"host": self.device_config["host"],
"port": self.device_config["port"],
},
)
@@ -3,6 +3,45 @@
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"config_flow": true,
"dhcp": [
{ "hostname": "rollergate*" },
{ "hostname": "gatebox*" },
{ "hostname": "doorbox*" },
{ "hostname": "shutterbox*" },
{ "hostname": "switchbox*" },
{ "hostname": "dimmerbox*" },
{ "hostname": "dacbox*" },
{ "hostname": "wlightbox*" },
{ "hostname": "pixelbox*" },
{ "hostname": "saunabox*" },
{ "hostname": "thermobox*" },
{ "hostname": "tempsensor*" },
{ "hostname": "energymeter*" },
{ "hostname": "airsensor*" },
{ "hostname": "humiditysensor*" },
{ "hostname": "rainsensor*" },
{ "hostname": "floodsensor*" },
{ "hostname": "luxsensor*" },
{ "hostname": "inputsensor*" },
{ "hostname": "opensensor*" },
{ "hostname": "windsensor*" },
{ "hostname": "co2sensor*" },
{ "hostname": "simongo*" },
{ "hostname": "sabaj-k-smrt*" },
{ "hostname": "rico*" },
{ "hostname": "smartrollergate*" },
{ "hostname": "darco_ero_32ws_0*" },
{ "hostname": "pergoladc*" },
{ "hostname": "seltsmartscreen*" },
{ "hostname": "seltvenetianblind*" },
{ "hostname": "doorunitbox*" },
{ "hostname": "drutexsmart*" },
{ "hostname": "swingatecontroller*" },
{ "hostname": "windowopener*" },
{ "hostname": "smartawning*" },
{ "hostname": "smartshade*" },
{ "hostname": "smartshutter*" }
],
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
@@ -4,6 +4,7 @@
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"authorization_required": "The BleBox device requires authentication.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
@@ -18,6 +19,10 @@
},
"flow_title": "{name} ({host})",
"step": {
"confirm_discovery": {
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
"title": "BleBox device discovered"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
+1 -1
View File
@@ -21,5 +21,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["blinkpy"],
"requirements": ["blinkpy==0.25.2"]
"requirements": ["blinkpy==0.25.6"]
}
@@ -26,6 +26,12 @@
"description": "The credentials for {username} need to be updated",
"title": "Re-authenticate Blink"
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
}
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["bluecurrent_api"],
"requirements": ["bluecurrent-api==1.3.2"]
"requirements": ["bluecurrent-api==1.3.3"]
}
+1 -1
View File
@@ -105,7 +105,7 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.6"],
"requirements": ["pyblu==2.0.8"],
"zeroconf": [
{
"type": "_musc._tcp.local."
@@ -118,7 +118,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
connections={(dr.CONNECTION_NETWORK_MAC, shc_info.unique_id)},
identifiers={(DOMAIN, shc_info.unique_id)},
manufacturer="Bosch",
name=entry.title,
@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["boschshcpy"],
"requirements": ["boschshcpy==0.2.107"],
"requirements": ["boschshcpy==0.2.111"],
"zeroconf": [
{
"name": "bosch shc*",
+8 -1
View File
@@ -118,7 +118,14 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
return False
except (NetworkTimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connect_failed",
translation_placeholders={
"host": api.host[0],
"error": str(err),
},
) from err
except BroadlinkException as err:
_LOGGER.error(
@@ -89,6 +89,9 @@
}
},
"exceptions": {
"connect_failed": {
"message": "Failed to connect to the device at {host}: {error}"
},
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
+2 -6
View File
@@ -31,11 +31,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -75,7 +71,7 @@ def get_bsblan_device_info(
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
connections={(CONNECTION_NETWORK_MAC, device.MAC)},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["buienradar", "vincenty"],
"requirements": ["buienradar==1.0.6"]
"requirements": ["buienradar==1.0.9"]
}
@@ -24,7 +24,7 @@ from homeassistant.components.websocket_api import (
ActiveConnection,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import CONF_EVENT, STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
HomeAssistant,
@@ -45,7 +45,6 @@ from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
from .const import (
CONF_EVENT,
DATA_COMPONENT,
DOMAIN,
EVENT_DESCRIPTION,
@@ -13,9 +13,6 @@ if TYPE_CHECKING:
DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
# pylint: disable-next=home-assistant-duplicate-const
CONF_EVENT = "event"
class CalendarEntityFeature(IntFlag):
"""Supported features of the calendar entity."""
@@ -3,11 +3,7 @@
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -69,7 +65,7 @@ class CieloDeviceEntity(CieloBaseEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="Cielo",
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.3"]
"requirements": ["aiocomelit==2.0.5"]
}
@@ -8,7 +8,7 @@ from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
from homeassistant.const import MATCH_ALL, SERVICE_RELOAD
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -53,7 +53,6 @@ from .const import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
)
from .default_agent import async_setup_default_agent
@@ -19,8 +19,6 @@ ATTR_AGENT_ID = "agent_id"
ATTR_CONVERSATION_ID = "conversation_id"
SERVICE_PROCESS = "process"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_RELOAD = "reload"
DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN)
@@ -5,6 +5,7 @@ import logging
from aiohttp import ClientConnectionError
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from pydaikin.factory import DaikinFactory
from homeassistant.const import (
@@ -56,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host)
raise ConfigEntryNotReady from err
except DaikinException as err:
# pydaikin has no subclass hierarchy for transient vs permanent errors.
# DaikinException during factory/init almost always means the device is not
# yet ready (e.g. "Empty values." when the unit hasn't finished booting),
# so treat all factory-time DaikinExceptions as transient.
_LOGGER.debug("DaikinException from %s: %s", host, err)
raise ConfigEntryNotReady from err
coordinator = DaikinCoordinator(hass, entry, device)
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
"requirements": ["datadog==0.52.0"]
"requirements": ["datadog==0.52.1"]
}
@@ -6,5 +6,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["debugpy==1.8.17"]
"requirements": ["debugpy==1.8.21"]
}
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.3.2"],
"requirements": ["denonavr==1.3.3"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
@@ -9,6 +9,6 @@
"iot_class": "local_push",
"loggers": ["HomeControl", "Mydevolo", "MprmRest", "MprmWebsocket", "Mprm"],
"quality_scale": "silver",
"requirements": ["devolo-home-control-api==0.19.0"],
"requirements": ["devolo-home-control-api==0.19.1"],
"zeroconf": ["_dvl-deviceapi._tcp.local."]
}
+1 -1
View File
@@ -16,7 +16,7 @@
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.2.7",
"aiodiscover==3.3.1",
"aiodiscover==3.3.2",
"cached-ipaddress==1.1.2"
]
}
@@ -19,11 +19,19 @@ from .coordinator import DucoConfigEntry
TO_REDACT = {
CONF_HOST,
"mac",
"Mac",
"host_name",
"HostName",
"serial_board_box",
"SerialBoardBox",
"serial_board_comm",
"SerialBoardComm",
"serial_duco_box",
"SerialDucoBox",
"serial_duco_comm",
"SerialDucoComm",
"WifiApKey",
"WifiApSsid",
}
+12 -2
View File
@@ -95,7 +95,12 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda node: node.sensor.co2 if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
),
DucoSensorEntityDescription(
key="iaq_co2",
@@ -104,7 +109,12 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_co2 if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.VLVCO2, NodeType.VLVCO2RH),
node_types=(
NodeType.BSCO2,
NodeType.UCCO2,
NodeType.VLVCO2,
NodeType.VLVCO2RH,
),
),
DucoSensorEntityDescription(
key="humidity",
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.6.0"],
"requirements": ["eheimdigital==1.7.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]
+2 -8
View File
@@ -1,11 +1,7 @@
"""Base entity for the Elgato integration."""
from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -33,6 +29,4 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]):
hw_version=str(coordinator.data.info.hardware_board_type),
)
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(mac))
}
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
@@ -93,7 +93,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
if "mac" in iface and iface["mac"] is not None
}
self.device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
(CONNECTION_NETWORK_MAC, iface["mac"])
for iface in about["info"]["ifaces"]
if "mac" in iface and iface["mac"] is not None
}
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.8"],
"requirements": ["pyenphase==2.4.9"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
@@ -19,7 +19,7 @@
"requirements": [
"aioesphomeapi==45.3.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.9.1"
"bleak-esphome==3.9.4"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -57,6 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: CometBlueConfigEntry) ->
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
name=f"{ble_device_info['model']} {cometblue_device.device.address}",
manufacturer=ble_device_info["manufacturer"],
model=ble_device_info["model"],
@@ -14,5 +14,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["bleak", "fjaraskupan"],
"requirements": ["fjaraskupan==2.3.3"]
"requirements": ["fjaraskupan==2.3.4"]
}
@@ -13,6 +13,11 @@
"discovery_confirm": {
"description": "Do you want to set up {model} {id} ({ipaddr})?"
},
"pick_device": {
"data": {
"device": "[%key:common::config_flow::data::device%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
+1 -1
View File
@@ -5,7 +5,7 @@
"data_description_password": "Password for the FRITZ!Box.",
"data_description_port": "Leave empty to use the default port.",
"data_description_ssl": "Use SSL to connect to the FRITZ!Box.",
"data_description_username": "Username for the FRITZ!Box.",
"data_description_username": "Username for the FRITZ!Box. FRITZ!Powerline devices ignore this information and accept any value.",
"data_feature_device_tracking": "Enable network device tracking"
},
"config": {
+1 -1
View File
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["fyta_cli"],
"quality_scale": "platinum",
"requirements": ["fyta_cli==0.7.2"]
"requirements": ["fyta_cli==0.7.3"]
}
@@ -65,14 +65,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
async def async_step_confirm(
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/geniushub",
"iot_class": "local_polling",
"loggers": ["geniushubclient"],
"requirements": ["geniushub-client==0.7.1"]
"requirements": ["geniushub-client==0.7.4"]
}
@@ -11,6 +11,7 @@
"step": {
"user": {
"data": {
"device": "[%key:common::config_flow::data::device%]",
"ip_address": "[%key:common::config_flow::data::ip%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
@@ -230,11 +230,19 @@ class GoogleGenerativeAISttEntity(
f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}",
)
prompt = self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
if metadata.language:
prompt = (
f"{prompt}\n"
f"The spoken language is {metadata.language}. "
f"Transcribe in that language."
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
contents=[
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
prompt,
Part.from_bytes(
data=audio_data,
mime_type=f"audio/{metadata.format.value}",
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["gtts"],
"requirements": ["gTTS==2.5.3"]
"requirements": ["gTTS==2.5.4"]
}
@@ -0,0 +1,91 @@
"""Home Assistant integration for Greencell EVSE devices."""
import asyncio
from collections.abc import Callable
import json
import logging
from greencell_client.access import GreencellAccess, GreencellHaAccessLevel
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from .const import CONF_SERIAL_NUMBER, DISCOVERY_TIMEOUT, GREENCELL_DISC_TOPIC
from .models import GreencellConfigEntry, GreencellRuntimeData
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
def make_ready_handler(
serial: str, event: asyncio.Event
) -> Callable[[ReceiveMessage], None]:
"""Create an MQTT message handler that sets event when device matches serial."""
@callback
def _on_message(message: ReceiveMessage) -> None:
if event.is_set():
return
try:
data = json.loads(message.payload)
except ValueError, TypeError:
return
if message.topic == GREENCELL_DISC_TOPIC:
if data.get("id") != serial:
return
elif data.get("id") and data["id"] != serial:
return
event.set()
return _on_message
async def async_setup_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Set up Greencell from a config entry."""
if not await mqtt.async_wait_for_mqtt_client(hass):
raise ConfigEntryNotReady("MQTT integration is not available")
serial: str = entry.data[CONF_SERIAL_NUMBER]
device_ready_event = asyncio.Event()
on_message = make_ready_handler(serial, device_ready_event)
try:
unsub_disc = await mqtt.async_subscribe(hass, GREENCELL_DISC_TOPIC, on_message)
unsub_volt = await mqtt.async_subscribe(
hass, f"/greencell/evse/{serial}/voltage", on_message
)
try:
async with asyncio.timeout(DISCOVERY_TIMEOUT):
await device_ready_event.wait()
finally:
unsub_disc()
unsub_volt()
except TimeoutError as err:
raise ConfigEntryNotReady(f"No initial data from device {serial}") from err
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT error: {err}") from err
entry.runtime_data = GreencellRuntimeData(
access=GreencellAccess(GreencellHaAccessLevel.EXECUTE),
current_data=ElecData3Phase(),
voltage_data=ElecData3Phase(),
power_data=ElecDataSinglePhase(),
state_data=ElecDataSinglePhase(),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,192 @@
"""Config flow for Greencell EVSE integration in Home Assistant."""
import asyncio
from collections.abc import Callable
import json
import logging
from typing import Any
from greencell_client.utils import GreencellUtils
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from . import const
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_BROADCAST_TOPIC,
GREENCELL_DISC_TOPIC,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
)
_LOGGER = logging.getLogger(__name__)
class EVSEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Greencell EVSE devices."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, dict[str, Any]] = {}
self._discovered_serial: str | None = None
self._discovery_event: asyncio.Event | None = None
self._remove_listener: Callable | None = None
def _get_device_name(self, serial: str) -> str:
"""Determine the device name based on the serial number."""
return (
GREENCELL_HABU_DEN
if GreencellUtils.device_is_habu_den(serial)
else GREENCELL_OTHER_DEVICE
)
@callback
def _async_mqtt_message_received(self, msg: ReceiveMessage) -> None:
"""Handle incoming MQTT messages on the discovery topic."""
try:
payload = json.loads(msg.payload)
except json.JSONDecodeError, AttributeError:
return
serial = payload.get("id")
if isinstance(serial, str) and serial.strip():
self._discovered[serial] = payload
if self._discovery_event:
self._discovery_event.set()
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> config_entries.ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
try:
payload = json.loads(discovery_info.payload)
serial = payload.get("id")
except json.JSONDecodeError, AttributeError:
return self.async_abort(reason="invalid_discovery_data")
if not isinstance(serial, str) or not serial.strip():
return self.async_abort(reason="invalid_discovery_data")
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
self._discovered_serial = serial
device_name = self._get_device_name(serial)
self.context.update({"title_placeholders": {"name": f"{device_name} {serial}"}})
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Confirm addition of a discovered device."""
assert self._discovered_serial is not None
serial = self._discovered_serial
if user_input is not None:
return self.async_create_entry(
title=f"{self._get_device_name(serial)} {serial}",
data={CONF_SERIAL_NUMBER: serial},
)
return self.async_show_form(
step_id="confirm",
description_placeholders={"serial": serial},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Manual step: start active discovery process."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_discover()
async def async_step_discover(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Discovery step: subscribe, broadcast, and wait for responses."""
self._discovery_event = asyncio.Event()
try:
self._remove_listener = await mqtt.async_subscribe(
self.hass,
GREENCELL_DISC_TOPIC,
self._async_mqtt_message_received,
)
except HomeAssistantError, ValueError:
return self.async_abort(reason="mqtt_subscription_failed")
try:
payload = json.dumps({"name": "BROADCAST"})
await mqtt.async_publish(
self.hass, GREENCELL_BROADCAST_TOPIC, payload, qos=0, retain=False
)
try:
await asyncio.wait_for(
self._discovery_event.wait(), timeout=const.DISCOVERY_TIMEOUT
)
# Grace period for additional devices
await asyncio.sleep(0.5)
except TimeoutError:
_LOGGER.debug("Discovery timed out waiting for device responses")
finally:
self._remove_listener()
if not self._discovered:
return self.async_abort(reason="no_discovery_data")
if len(self._discovered) == 1:
serial = next(iter(self._discovered))
return await self._async_create_entry(serial)
return await self.async_step_select()
async def async_step_select(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Let the user select one of the discovered devices."""
if user_input is not None:
serial = user_input[CONF_SERIAL_NUMBER]
return await self._async_create_entry(serial)
return self.async_show_form(
step_id="select",
data_schema=vol.Schema(
{
vol.Required(CONF_SERIAL_NUMBER): vol.In(
list(self._discovered.keys())
)
}
),
description_placeholders={"count": str(len(self._discovered))},
)
async def _async_create_entry(self, serial: str) -> config_entries.ConfigFlowResult:
"""Finalize entry creation for selected device."""
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
device_name = self._get_device_name(serial)
title = f"{device_name} {serial}"
_LOGGER.info("Discovered and added device: %s", title)
return self.async_create_entry(
title=title,
data={CONF_SERIAL_NUMBER: serial},
)
@@ -0,0 +1,31 @@
"""Core constants for the Greencell EVSE Home Assistant integration."""
from typing import Final
# Greencell constants
DOMAIN = "greencell"
MANUFACTURER: Final = "Greencell"
# Maximal current configuration
DEFAULT_MIN_CURRENT = 6
DEFAULT_MAX_CURRENT_OTHER = 16
DEFAULT_MAX_CURRENT_HABU_DEN = 32
# Topics
GREENCELL_BROADCAST_TOPIC = "/greencell/broadcast"
GREENCELL_DISC_TOPIC = "/greencell/broadcast/device"
# Device names
GREENCELL_HABU_DEN = "Habu Den"
GREENCELL_OTHER_DEVICE = "Greencell Device"
# Other constants
DISCOVERY_MIN_TIMEOUT = 5.0
DISCOVERY_TIMEOUT = 30.0
SET_CURRENT_RETRY_TIME = 15
CONF_SERIAL_NUMBER = "serial_number"
@@ -0,0 +1,30 @@
{
"entity": {
"sensor": {
"current_l1": {
"default": "mdi:flash"
},
"current_l2": {
"default": "mdi:flash"
},
"current_l3": {
"default": "mdi:flash"
},
"power": {
"default": "mdi:battery-charging-high"
},
"status": {
"default": "mdi:ev-plug-type2"
},
"voltage_l1": {
"default": "mdi:meter-electric"
},
"voltage_l2": {
"default": "mdi:meter-electric"
},
"voltage_l3": {
"default": "mdi:meter-electric"
}
}
}
}
@@ -0,0 +1,13 @@
{
"domain": "greencell",
"name": "Greencell",
"codeowners": ["@BrzezowskiGC"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/greencell",
"integration_type": "device",
"iot_class": "local_push",
"mqtt": ["/greencell/broadcast/device"],
"quality_scale": "bronze",
"requirements": ["greencell_client==1.0.3"]
}
@@ -0,0 +1,22 @@
"""Type definitions for Greencell integration."""
from dataclasses import dataclass
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.config_entries import ConfigEntry
@dataclass
class GreencellRuntimeData:
"""Runtime data for Greencell integration."""
access: GreencellAccess
current_data: ElecData3Phase
voltage_data: ElecData3Phase
power_data: ElecDataSinglePhase
state_data: ElecDataSinglePhase
type GreencellConfigEntry = ConfigEntry[GreencellRuntimeData]
@@ -0,0 +1,63 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions or services.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,341 @@
"""Home Assistant integration module for Greencell EVSE sensor entities over MQTT."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from greencell_client.mqtt_parser import MqttParser
from greencell_client.utils import GreencellUtils
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
MANUFACTURER,
)
from .models import GreencellConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GreencellSensorDescription(SensorEntityDescription):
"""Describe a Greencell sensor."""
value_fn: Callable[[Any], StateType]
SENSOR_DESCRIPTIONS = (
GreencellSensorDescription(
key="current_l1",
translation_key="current_l1",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l2",
translation_key="current_l2",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l3",
translation_key="current_l3",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="voltage_l1",
translation_key="voltage_l1",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l2",
translation_key="voltage_l2",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l3",
translation_key="voltage_l3",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="power",
translation_key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=1,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="status",
translation_key="status",
device_class=SensorDeviceClass.ENUM,
options=[
"idle",
"connected",
"waiting_for_car",
"charging",
"finished",
"error_car",
"error_evse",
],
value_fn=lambda data: str(data).lower() if isinstance(data, str) else None,
),
)
# --- Config Flow Setup ---
async def async_setup_entry(
hass: HomeAssistant,
entry: GreencellConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Greencell EVSE sensors from a config entry."""
serial_number: str = entry.data[CONF_SERIAL_NUMBER]
mqtt_topic_current = f"/greencell/evse/{serial_number}/current"
mqtt_topic_voltage = f"/greencell/evse/{serial_number}/voltage"
mqtt_topic_power = f"/greencell/evse/{serial_number}/power"
mqtt_topic_status = f"/greencell/evse/{serial_number}/status"
mqtt_topic_device_state = f"/greencell/evse/{serial_number}/device_state"
desc_map = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
runtime = entry.runtime_data
access = runtime.access
current_data_obj = runtime.current_data
voltage_data_obj = runtime.voltage_data
power_data_obj = runtime.power_data
state_data_obj = runtime.state_data
data_mapping = {
"current": current_data_obj,
"voltage": voltage_data_obj,
"power": power_data_obj,
"status": state_data_obj,
}
sensors: list[HabuSensor] = [
Habu3PhaseSensor(
sensor_data=data_mapping[description.key.split("_")[0]],
phase=description.key.split("_")[-1],
sensor_type=description.key,
serial_number=serial_number,
access=access,
description=description,
)
for description in SENSOR_DESCRIPTIONS
if description.key.startswith(("current_l", "voltage_l"))
]
sensors.extend(
HabuSingleSensor(
sensor_data=data_mapping[key],
serial_number=serial_number,
sensor_type=key,
access=access,
description=desc_map[key],
)
for key in ("power", "status")
)
@callback
def current_message_received(msg: ReceiveMessage) -> None:
"""Handle the current message."""
MqttParser.parse_3phase_msg(msg.payload, current_data_obj)
@callback
def voltage_message_received(msg: ReceiveMessage) -> None:
"""Handle the voltage message."""
MqttParser.parse_3phase_msg(msg.payload, voltage_data_obj)
@callback
def power_message_received(msg: ReceiveMessage) -> None:
"""Handle the power message."""
MqttParser.parse_single_phase_msg(msg.payload, "momentary", power_data_obj)
@callback
def status_message_received(msg: ReceiveMessage) -> None:
"""Handle the status message. If the device is unavailable, disable the entity."""
str_payload = (
msg.payload.decode("utf-8", errors="ignore")
if isinstance(msg.payload, (bytes, bytearray))
else str(msg.payload)
)
if "UNAVAILABLE" in str_payload or "OFFLINE" in str_payload:
access.update("UNAVAILABLE")
else:
MqttParser.parse_single_phase_msg(msg.payload, "state", state_data_obj)
@callback
def device_state_message_received(msg: ReceiveMessage) -> None:
"""Handle the device state message. If device was unavailable, enable the entity."""
access.on_msg(msg.payload)
try:
for topic, handler in (
(mqtt_topic_current, current_message_received),
(mqtt_topic_voltage, voltage_message_received),
(mqtt_topic_power, power_message_received),
(mqtt_topic_status, status_message_received),
(mqtt_topic_device_state, device_state_message_received),
):
unsub = await mqtt.async_subscribe(hass, topic, handler)
if unsub is not None:
entry.async_on_unload(unsub)
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT is unavailable: {err}") from err
async_add_entities(sensors)
class HabuSensor(SensorEntity):
"""Abstract base class for Habu sensors integration."""
entity_description: GreencellSensorDescription
_attr_has_entity_name = True
_remove_listener: Callable[[], None] | None = None
def __init__(
self,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the sensor entity."""
self._sensor_type = sensor_type
self._serial_number = serial_number
self._access = access
self.entity_description = description
self._attr_unique_id = f"{serial_number}_{description.key}"
if GreencellUtils.device_is_habu_den(self._serial_number):
device_name = GREENCELL_HABU_DEN
else:
device_name = GREENCELL_OTHER_DEVICE
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=f"{device_name} {serial_number}",
manufacturer=MANUFACTURER,
model=device_name,
serial_number=serial_number,
)
@property
def available(self) -> bool:
"""Return True if the entity is available."""
return not self._access.is_disabled()
async def async_added_to_hass(self) -> None:
"""Register the entity with Home Assistant."""
unsub = self._access.register_listener(self._schedule_update)
if unsub is not None:
self.async_on_remove(unsub)
def _schedule_update(self) -> None:
"""Schedule an update for the entity."""
self.async_schedule_update_ha_state()
class Habu3PhaseSensor(HabuSensor):
"""Abstract class for 3-phase sensors (e.g. current, voltage)."""
def __init__(
self,
sensor_data: ElecData3Phase,
phase: str,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the 3-phase sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._sensor_data = sensor_data
self._phase = phase
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._sensor_data.get_value(self._phase)
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
class HabuSingleSensor(HabuSensor):
"""Example class for sensors that return a single value."""
def __init__(
self,
sensor_data: ElecDataSinglePhase,
serial_number: str,
sensor_type: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the single-value sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._value = sensor_data
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._value.data
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
@@ -0,0 +1,70 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_discovery_data": "The received discovery data is invalid.",
"mqtt_not_configured": "MQTT is not configured. Please configure MQTT first.",
"mqtt_not_connected": "MQTT is not connected. Ensure the MQTT broker is running and configured.",
"mqtt_subscription_failed": "Failed to subscribe to the MQTT topic for discovery.",
"no_discovery_data": "No discovery data received. Ensure the device is online and broadcasting."
},
"step": {
"confirm": {
"description": "A Greencell device with serial number {serial} was discovered. Do you want to add it?",
"title": "Greencell device discovered"
},
"select": {
"data": {
"serial_number": "Device serial number"
},
"data_description": {
"serial_number": "Select the device you want to add to Home Assistant"
},
"description": "Multiple Greencell devices were found (total: {count}). Please choose which one you want to configure.",
"title": "Select your device"
},
"user": {
"description": "The integration will try to discover your EVSE devices over MQTT.",
"title": "Set up your Greencell HabuDen EVSE"
}
}
},
"entity": {
"sensor": {
"current_l1": {
"name": "Current phase L1"
},
"current_l2": {
"name": "Current phase L2"
},
"current_l3": {
"name": "Current phase L3"
},
"power": {
"name": "Power"
},
"status": {
"name": "Status",
"state": {
"charging": "[%key:common::state::charging%]",
"connected": "[%key:common::state::connected%]",
"error_car": "Car error",
"error_evse": "EVSE error",
"finished": "Finished",
"idle": "[%key:common::state::idle%]",
"waiting_for_car": "Waiting for car"
}
},
"voltage_l1": {
"name": "Voltage phase L1"
},
"voltage_l2": {
"name": "Voltage phase L2"
},
"voltage_l3": {
"name": "Voltage phase L3"
}
}
}
}
@@ -201,7 +201,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
# V1 API returns current_power in kW, convert to W
total_info["invTodayPpv"] = total_info["current_power"] * 1000
else:
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"requirements": ["pyHik==0.4.2"]
"requirements": ["pyHik==0.4.3"]
}
+1 -1
View File
@@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
connections.add((dr.CONNECTION_NETWORK_MAC, mac))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
@@ -151,6 +151,13 @@ class HolidayCalendarEntity(CalendarEntity):
"""Set up first update."""
self._update_state_and_setup_listener()
async def async_will_remove_from_hass(self) -> None:
"""Cancel listener when removing."""
await super().async_will_remove_from_hass()
if self.unsub:
self.unsub()
self.unsub = None
def update_event(self, now: datetime) -> CalendarEvent | None:
"""Return the next upcoming event."""
next_holiday = None
+1 -3
View File
@@ -83,9 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
},
connections={(dr.CONNECTION_NETWORK_MAC, homee.settings.mac_address)},
identifiers={(DOMAIN, homee.settings.uid)},
manufacturer="homee",
name=homee.settings.homee_name,
@@ -48,7 +48,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
@@ -177,7 +177,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Determine if the device is a homekit bridge or accessory."""
dev_reg = dr.async_get(self.hass)
device = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))}
connections={(dr.CONNECTION_NETWORK_MAC, hkid)}
)
if device is None:
@@ -22,4 +22,7 @@ async def async_get_config_entry_diagnostics(
anonymized = handle_config(json_state, anonymize=True)
config = json.loads(anonymized)
return async_redact_data(config, TO_REDACT_CONFIG)
return {
"websocket": hap.websocket_diagnostics(),
"config": async_redact_data(config, TO_REDACT_CONFIG),
}
@@ -164,9 +164,11 @@ class HomematicipHAP:
self.set_all_to_unavailable()
elif self._ws_connection_closed.is_set():
_LOGGER.info("HMIP access point has reconnected to the cloud")
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._start_get_state_task()
@callback
def async_create_entity(self, *args, **kwargs) -> None:
@@ -180,44 +182,103 @@ class HomematicipHAP:
await asyncio.sleep(30)
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
def websocket_diagnostics(self) -> dict[str, Any]:
"""Return websocket diagnostics dict (None values omitted)."""
diagnostics = {
"last_disconnect_reason": self.home.websocket_last_disconnect_reason(),
"reconnect_attempts": self.home.websocket_reconnect_attempt_count(),
"seconds_since_last_message": (
self.home.websocket_seconds_since_last_message()
),
"message_count": self.home.websocket_message_count(),
}
return {k: v for k, v in diagnostics.items() if v is not None}
def _websocket_diagnostic_context(self) -> str:
"""Return a single-line summary of websocket diagnostics for logs."""
diagnostics = self.websocket_diagnostics()
if not diagnostics:
return "no diagnostics available"
return ", ".join(f"{k}={v!r}" for k, v in diagnostics.items())
@callback
def _start_get_state_task(self) -> None:
"""Cancel any in-flight reconnect refresh and start a new one."""
if self._get_state_task is not None and not self._get_state_task.done():
_LOGGER.debug(
"Cancelling previous HomematicIP reconnect state refresh task"
)
self._get_state_task.cancel()
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
async def _try_get_state(self) -> None:
"""Call get_state in a loop until no error occurs.
"""Refresh state after a websocket reconnect.
Uses exponential backoff on error.
Delegates the bounded websocket wait + retry-with-exponential-backoff
to the homematicip library (``refresh_state_after_reconnect_async``),
and only handles HA-specific concerns here:
- on authentication failure, trigger reauth
- clear the per-device ``unreach`` flag and signal entity updates
(the workaround for core#160048)
"""
try:
await self.home.refresh_state_after_reconnect_async()
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
return
self._post_state_refresh()
# Wait until WebSocket connection is established.
while not self.home.websocket_is_connected():
await asyncio.sleep(2)
async def _on_websocket_stale(self, severity: str, seconds_since: float) -> None:
"""Log a websocket-stale event surfaced by the library.
delay = 8
max_delay = 1500
while True:
try:
await self.get_state()
break
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err
)
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
The library polls staleness internally and fires this callback once
per severity per stuck period; it re-arms when fresh messages arrive.
We just translate severity to a log level.
"""
log = _LOGGER.error if severity == "error" else _LOGGER.warning
log(
"HomematicIP websocket has not received a message for "
"%.0f seconds while reporting connected",
seconds_since,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
await self.home.get_current_state_async()
self._post_state_refresh()
def _post_state_refresh(self) -> None:
"""Apply HA-specific post-processing after a state refresh.
``set_all_to_unavailable`` marked every device unreach=True on
disconnect; ``get_current_state_async`` only clears that flag for
devices whose state actually changed during the outage, so the rest
stay stuck unavailable after reconnect. Force-clear for all devices.
Trade-off: a device that is *genuinely* unreachable on the cloud
side will briefly appear available until its next state push
corrects it. That self-corrects, while the previous behaviour left
entities stuck unavailable indefinitely (core #160048).
"""
for device in self.home.devices:
device.unreach = False
self.update_all()
def get_state_finished(self, future) -> None:
"""Execute when try_get_state coroutine has finished."""
try:
future.result()
except asyncio.CancelledError:
_LOGGER.debug("HomematicIP reconnect state refresh task was cancelled")
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error updating state after HMIP access point reconnect: %s", err
@@ -246,6 +307,7 @@ class HomematicipHAP:
home.set_on_connected_handler(self.ws_connected_handler)
home.set_on_disconnected_handler(self.ws_disconnected_handler)
home.set_on_reconnect_handler(self.ws_reconnected_handler)
home.set_on_websocket_stale_handler(self._on_websocket_stale)
async def async_reset(self) -> bool:
"""Close the websocket connection."""
@@ -275,23 +337,28 @@ class HomematicipHAP:
"""Handle websocket connected."""
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set():
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
self._start_get_state_task()
async def ws_disconnected_handler(self) -> None:
"""Handle websocket disconnection."""
_LOGGER.warning("Websocket connection to HomematicIP Cloud closed")
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection."""
_LOGGER.info(
"Websocket connection to HomematicIP Cloud trying"
" to reconnect due to reason: %s",
"Websocket connection to HomematicIP Cloud trying to reconnect due to "
"reason: %s",
reason,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
@@ -27,6 +27,7 @@ from homematicip.device import (
PassageDetector,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
RotaryHandleSensor,
SmokeDetector,
SoilMoistureSensorInterface,
SwitchMeasuring,
@@ -166,6 +167,7 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
}
TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"]
WINDOW_STATE_VALUES = ["open", "closed", "tilted"]
def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
@@ -204,6 +206,9 @@ def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]:
RoomControlDeviceAnalog: lambda device: [
HomematicipTemperatureSensor(hap, device),
],
RotaryHandleSensor: lambda device: [
HomematicipWindowStateSensor(hap, device),
],
LightSensor: lambda device: [
HomematicipIlluminanceSensor(hap, device),
],
@@ -498,6 +503,24 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
return state_attr
class HomematicipWindowStateSensor(HomematicipGenericEntity, SensorEntity):
"""Representation of the HomematicIP rotary handle window state sensor."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = WINDOW_STATE_VALUES
_attr_translation_key = "window_state"
def __init__(self, hap: HomematicipHAP, device: RotaryHandleSensor) -> None:
"""Initialize the window state sensor."""
super().__init__(hap, device, feature_id="window_state")
@property
def native_value(self) -> str | None:
"""Return the state."""
window_state = getattr(self._device, "windowState", None)
return window_state.lower() if window_state is not None else None
class HomematicipFloorTerminalBlockMechanicChannelValve(
HomematicipGenericEntity, SensorEntity
):
@@ -98,6 +98,14 @@
"non_neutral": "Non-neutral",
"tilted": "Tilted"
}
},
"window_state": {
"name": "Window state",
"state": {
"closed": "[%key:common::state::closed%]",
"open": "[%key:common::state::open%]",
"tilted": "Tilted"
}
}
}
},
@@ -50,14 +50,12 @@ def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
translation_key="auth_failed",
) from error
except HomevoltConnectionError as error:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
@@ -172,10 +172,10 @@
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"communication_error": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
"message": "Error communicating with the Homevolt battery: {error}"
},
"unknown_error": {
"message": "[%key:common::config_flow::error::unknown%]"
"message": "Unknown error from the Homevolt battery: {error}"
}
}
}
+2 -1
View File
@@ -18,7 +18,8 @@
"step": {
"init": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"id": "Hue bridge"
},
"data_description": {
"host": "The hostname or IP address of your Hue bridge."
+5 -3
View File
@@ -4,7 +4,7 @@ import logging
from huum.const import SaunaStatus
from homeassistant.components.number import NumberEntity
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -34,7 +34,9 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
"""Representation of a steamer."""
_attr_translation_key = "humidity"
_attr_native_max_value = 10
_attr_device_class = NumberDeviceClass.HUMIDITY
_attr_native_unit_of_measurement = "%"
_attr_native_max_value = 40
_attr_native_min_value = 0
_attr_native_step = 1
@@ -47,7 +49,7 @@ class HuumSteamer(HuumBaseEntity, NumberEntity):
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.coordinator.data.target_humidity
return self.coordinator.data.humidity
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
+3 -1
View File
@@ -18,6 +18,7 @@ from .const import (
STORAGE_KEY,
STORAGE_VERSION,
)
from .media_source import async_setup_mediasource, async_setup_photo_cache
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -27,7 +28,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up iCloud integration."""
async_setup_services(hass)
async_setup_mediasource(hass)
return True
@@ -61,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
entry.runtime_data = account
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_photo_cache(hass, account)
return True
+6 -1
View File
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
import operator
from typing import Any
from typing import TYPE_CHECKING, Any
from pyicloud import PyiCloudService
from pyicloud.exceptions import (
@@ -55,6 +55,9 @@ from .const import (
DOMAIN,
)
if TYPE_CHECKING:
from .media_source import PhotoCache
_LOGGER = logging.getLogger(__name__)
type IcloudConfigEntry = ConfigEntry[IcloudAccount]
@@ -95,6 +98,8 @@ class IcloudAccount:
self._unsub_fetch: CALLBACK_TYPE | None = None
self.listeners: list[CALLBACK_TYPE] = []
self.photo_cache: PhotoCache | None = None
def setup(self) -> None:
"""Set up an iCloud account."""
try:
@@ -3,6 +3,7 @@
"name": "Apple iCloud",
"codeowners": ["@Quentame", "@nzapponi"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/icloud",
"integration_type": "hub",
"iot_class": "cloud_polling",
@@ -0,0 +1,671 @@
"""Expose iCloud photo albums as a media source."""
from base64 import b64decode, b64encode
import binascii
from collections import OrderedDict
from dataclasses import dataclass
import logging
import threading
import urllib.parse
from aiohttp import ClientTimeout, hdrs, web
from pyicloud.services.photos import (
AlbumContainer,
BasePhotoAlbum,
PhotoAlbumFolder,
PhotoAsset,
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.static import CACHE_HEADERS
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .account import IcloudAccount
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MAX_PHOTO_CACHE_SIZE = 1000
def async_setup_mediasource(hass: HomeAssistant) -> None:
"""Set up the iCloud media source."""
hass.http.register_view(IcloudMediaSourceView(hass))
async def async_get_media_source(hass: HomeAssistant) -> IcloudMediaSource:
"""Set up iCloud media source."""
return IcloudMediaSource(hass)
def _get_icloud_account_and_title(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> tuple[IcloudAccount, str]:
"""Get iCloud account from identifier. Also return the account title for display purposes."""
entry = hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, identifier.config_entry_id
)
if entry is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"entry": identifier.config_entry_id},
)
if getattr(entry, "runtime_data", None) is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return entry.runtime_data, entry.title
async def async_setup_photo_cache(hass, account):
"""Set up the photo cache for the iCloud account."""
if account.photo_cache is None:
account.photo_cache = PhotoCache()
async def _get_photo_library(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> AlbumContainer:
"""Get photo library."""
def get_photo_library_sync() -> AlbumContainer:
"""Get photo library synchronously."""
if icloud_account.api is None or icloud_account.api.photos is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return (
icloud_account.api.photos.shared_streams
if identifier.shared_album is True
else icloud_account.api.photos.albums
)
return await hass.async_add_executor_job(get_photo_library_sync)
async def _get_photo_album(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> BasePhotoAlbum:
"""Get photo album from identifier."""
def _find_album_sync() -> BasePhotoAlbum | None:
"""Find album synchronously."""
album: BasePhotoAlbum | None = (
albums.get(identifier.album_id) if albums and identifier.album_id else None
)
if not album:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="album_not_found",
)
return album
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(hass, icloud_account, identifier)
return await hass.async_add_executor_job(_find_album_sync)
async def _get_photo_asset(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> PhotoAsset:
"""Get photo asset asynchronously."""
def _get_photo_asset_sync(album: BasePhotoAlbum) -> PhotoAsset | None:
"""Get photo asset synchronously."""
for item in album.photos:
if item.id == identifier.photo_id and identifier.photo_id is not None:
PhotoCache.instance(icloud_account).set(identifier.photo_id, item)
return item
return None
icloud_account, _ = _get_icloud_account_and_title(hass, identifier)
if identifier.album_id is None or identifier.photo_id is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
photo: PhotoAsset | None = await hass.async_add_executor_job(
PhotoCache.instance(icloud_account).get, identifier.photo_id
)
if photo is None:
album: BasePhotoAlbum = await _get_photo_album(hass, icloud_account, identifier)
photo = await hass.async_add_executor_job(_get_photo_asset_sync, album)
if photo is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="photo_not_found",
)
return photo
async def _get_media_mime_type(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> str:
"""Get media MIME type asynchronously."""
photo: PhotoAsset = await _get_photo_asset(hass, identifier)
match photo.item_type:
case "image":
if photo.filename.lower().endswith(".png"):
return "image/png"
if photo.filename.lower().endswith(".heic"):
return "image/heic"
return "image/jpeg"
case "movie":
return "video/mp4"
case _:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="unsupported_media_type",
)
class PhotoCache:
"""Simple in-memory cache for PhotoAsset objects."""
@classmethod
def instance(cls, icloud_account: IcloudAccount) -> PhotoCache:
"""Get the account instance of the photo cache."""
if icloud_account.photo_cache is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
return icloud_account.photo_cache
def __init__(self, max_size: int = MAX_PHOTO_CACHE_SIZE) -> None:
"""Initialize the photo cache."""
self._cache: OrderedDict[str, PhotoAsset] = OrderedDict()
self._max_size = max_size
self._lock = threading.RLock()
def get(self, photo_id: str) -> PhotoAsset | None:
"""Get a photo from the cache."""
with self._lock:
photo = self._cache.get(photo_id)
if photo is not None:
# Move the accessed item to the end to show that it was recently used
self._cache.move_to_end(photo_id)
return photo
def set(self, photo_id: str, photo: PhotoAsset) -> None:
"""Set a photo in the cache."""
with self._lock:
self._cache[photo_id] = photo
if len(self._cache) > self._max_size:
self._cache.popitem(last=False)
@dataclass(kw_only=True)
class IcloudMediaSourceIdentifier:
"""Parse and represent an iCloud media source identifier.
Example identifier format: config_entry_id/album/album_id
Example identifier format: config_entry_id/shared/shared_album_id
Example identifier format: config_entry_id/album/album_id/photo_id
Example identifier format: config_entry_id/shared/shared_album_id/photo_id
"""
config_entry_id: str
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
@staticmethod
def from_identifier(identifier: str) -> IcloudMediaSourceIdentifier:
"""Initialize iCloud media source identifier."""
config_entry_id: str = ""
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
parts: list[str] = identifier.split("/") if identifier else []
for idx, part in enumerate(parts):
if idx == 0:
config_entry_id = part
elif idx == 1:
if part.lower() not in ("shared", "album"):
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="invalid_view_type",
)
shared_album = part.lower() == "shared"
elif idx == 2:
album_id = part
elif idx == 3:
photo_id = part
if not config_entry_id:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
return IcloudMediaSourceIdentifier(
config_entry_id=config_entry_id,
shared_album=shared_album,
album_id=album_id,
photo_id=photo_id,
)
def __str__(self) -> str:
"""Return string representation of the identifier."""
parts = [self.config_entry_id]
if self.shared_album is not None:
parts.append("shared" if self.shared_album else "album")
if self.album_id is not None:
parts.append(self.album_id)
if self.photo_id is not None:
parts.append(self.photo_id)
return "/".join(parts)
class IcloudMediaSource(MediaSource):
"""Provide iCloud media source."""
name = "iCloud"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source."""
super().__init__(DOMAIN)
self._hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve a media item to a playable object."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
mime_type = await _get_media_mime_type(self._hass, identifier)
return PlayMedia(
f"/api/icloud/media_source/serve/original/{b64encode(str(item.identifier).encode()).decode()}",
mime_type,
)
def _get_config_entries(self) -> list[ConfigEntry]:
"""Get iCloud config entries."""
return self._hass.config_entries.async_entries(
DOMAIN, include_disabled=False, include_ignore=False
)
async def _build_title_for_identifier(
self,
identifier: IcloudMediaSourceIdentifier | None,
) -> str:
"""Build title for media source identifier."""
title_parts = ["iCloud Media"]
icloud_account = None
if identifier and identifier.config_entry_id is not None:
icloud_account, title = _get_icloud_account_and_title(
self._hass, identifier
)
title_parts.append(title)
if identifier and identifier.shared_album is True:
title_parts.append("Shared Streams")
elif identifier and identifier.shared_album is False:
title_parts.append("Albums")
if icloud_account and identifier and identifier.album_id is not None:
album = await _get_photo_album(self._hass, icloud_account, identifier)
title_parts.append(album.title)
return " / ".join(title_parts)
async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
if not item.identifier:
return await self._async_build_icloud_accounts()
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
if identifier.shared_album is None:
return await self._async_build_album_types(identifier)
icloud_account, _ = _get_icloud_account_and_title(self._hass, identifier)
if identifier.album_id is None:
return await self._async_build_albums(identifier, icloud_account)
if identifier.photo_id is None:
return await self._async_build_photos(identifier, icloud_account)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_media_item",
)
async def _async_build_icloud_accounts(
self,
) -> BrowseMediaSource:
"""Handle browsing of different iCloud accounts."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(None),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(config_entry_id=entry.unique_id)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=entry.title,
can_play=False,
can_expand=True,
)
for entry in self._get_config_entries()
if entry.unique_id is not None
],
)
async def _async_build_album_types(
self,
identifier: IcloudMediaSourceIdentifier,
) -> BrowseMediaSource:
"""Handle browsing of album types (albums vs shared albums)."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=False,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Albums",
),
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=True,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Shared Streams",
),
],
)
async def _async_build_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of albums."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._browse_albums(identifier, icloud_account),
)
async def _async_build_photos(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of photos in an album."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._get_photo_list(identifier, icloud_account),
)
async def _browse_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Browse albums asynchronously."""
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(self._hass, icloud_account, identifier)
children: list[BrowseMediaSource] = []
if albums is not None:
for album in albums:
if isinstance(album, PhotoAlbumFolder):
continue
children.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=album.id,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title=album.title,
)
)
return children
async def _get_photo_list(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Get list of photos asynchronously."""
def _get_photo_list_sync(album: BasePhotoAlbum) -> list[BrowseMediaSource]:
"""Get list of photos synchronously."""
items: list[BrowseMediaSource] = []
for photo in album.photos:
PhotoCache.instance(icloud_account).set(photo.id, photo)
photo_id = IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=identifier.album_id,
photo_id=photo.id,
)
item = BrowseMediaSource(
domain=DOMAIN,
identifier=str(photo_id),
media_class=(
MediaClass.IMAGE
if photo.item_type == "image"
else MediaClass.VIDEO
),
media_content_type=(
MediaType.IMAGE
if photo.item_type == "image"
else MediaType.VIDEO
),
can_play=True,
can_expand=False,
title=photo.filename,
thumbnail=f"/api/icloud/media_source/serve/thumb{'' if photo.item_type == 'image' else '_image'}/{b64encode(str(photo_id).encode()).decode()}",
)
items.append(item)
return items
album: BasePhotoAlbum = await _get_photo_album(
self._hass, icloud_account, identifier
)
return await self._hass.async_add_executor_job(_get_photo_list_sync, album)
class IcloudMediaSourceView(HomeAssistantView):
"""Handle media serving via HTTP view."""
url = "/api/icloud/media_source/serve/{version}/{image_id}"
name = "api:icloud:media_source:serve"
requires_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source view."""
super().__init__()
self._hass = hass
self.session = async_get_clientsession(hass)
async def get(
self,
request: web.Request,
version: str,
image_id: str,
) -> web.StreamResponse:
"""Get the image from iCloud."""
try:
identifier = IcloudMediaSourceIdentifier.from_identifier(
b64decode(image_id, validate=True).decode()
)
except (Unresolvable, binascii.Error, UnicodeDecodeError) as err:
_LOGGER.error("Error decoding iCloud media source identifier: %s", err)
raise web.HTTPBadRequest from err
try:
photo = await _get_photo_asset(self._hass, identifier)
except Unresolvable as err:
_LOGGER.error("Error resolving iCloud media source: %s", err)
raise web.HTTPNotFound from err
url = photo.versions.get(version, {}).get("url")
if url is None and version.startswith("thumb"):
# try the medium version for thumbnails if the requested version is not available, as some videos only have a medium version and no separate thumbnail version
url = photo.versions.get(version.replace("thumb", "medium"), {}).get("url")
if url is None:
raise web.HTTPNotFound
request_headers = {}
if hdrs.RANGE in request.headers:
request_headers[hdrs.RANGE] = request.headers[hdrs.RANGE]
icloud_response = await self.session.get(
url,
timeout=ClientTimeout(
connect=15, sock_connect=15, sock_read=30, total=None
),
headers=request_headers,
)
response_headers: dict[str, str] = {}
response_headers.update(CACHE_HEADERS)
response_headers[hdrs.CONTENT_DISPOSITION] = (
f'attachment;filename="{urllib.parse.quote(photo.filename, safe="")}"'
)
for header in (
hdrs.CONTENT_TYPE,
hdrs.LAST_MODIFIED,
hdrs.ACCEPT_RANGES,
hdrs.CONTENT_RANGE,
):
if header in icloud_response.headers:
response_headers[header] = icloud_response.headers[header]
response = web.StreamResponse(
status=icloud_response.status,
reason=icloud_response.reason,
headers=response_headers,
)
await response.prepare(request)
try:
async for chunk in icloud_response.content.iter_chunked(65536):
await response.write(chunk)
except TimeoutError:
_LOGGER.warning(
"Timeout while reading iCloud, writing EOF",
)
finally:
icloud_response.release()
await response.write_eof()
return response
@@ -44,6 +44,41 @@
}
}
},
"exceptions": {
"account_not_initialized": {
"message": "Account not initialized: {entry}"
},
"album_not_found": {
"message": "Album not found"
},
"album_type_not_specified": {
"message": "Album type not specified"
},
"config_entry_not_found": {
"message": "Config entry not found for account: {entry}"
},
"config_entry_not_loaded": {
"message": "Config entry not loaded"
},
"incomplete_media_source_identifier": {
"message": "Incomplete media source identifier"
},
"invalid_media_source": {
"message": "Invalid media source"
},
"invalid_view_type": {
"message": "Invalid album view type"
},
"photo_not_found": {
"message": "Photo not found"
},
"unknown_media_item": {
"message": "Unknown media item"
},
"unsupported_media_type": {
"message": "Unsupported media type"
}
},
"services": {
"display_message": {
"description": "Displays a message on an Apple device.",
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyimouapi==1.2.7"]
"requirements": ["pyimouapi==1.2.8"]
}
@@ -11,6 +11,7 @@ from indevolt_api import (
IndevoltConfig,
IndevoltEnergyMode,
IndevoltRealtimeAction,
IndevoltRealtimeState,
)
from homeassistant.config_entries import ConfigEntry
@@ -109,6 +110,10 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Push/write data values to given key on the device."""
return await self.api.set_data(sensor_key, value)
def async_optimistic_update(self, read_key: str, value: Any) -> None:
"""Optimistically update coordinator data without fetching from device."""
self.async_set_updated_data({**self.data, read_key: value})
async def async_switch_energy_mode(
self, target_mode: IndevoltEnergyMode, refresh: bool = True
) -> None:
@@ -142,7 +147,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
if refresh:
await self.async_request_refresh()
self.async_optimistic_update(
IndevoltConfig.READ_ENERGY_MODE, target_mode
)
async def async_realtime_action(
self,
@@ -161,10 +168,15 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
match action:
case IndevoltRealtimeAction.CHARGE:
success = await self.api.charge(power, target_soc)
state = IndevoltRealtimeState.CHARGING
case IndevoltRealtimeAction.DISCHARGE:
success = await self.api.discharge(power, target_soc)
state = IndevoltRealtimeState.DISCHARGING
case IndevoltRealtimeAction.STOP:
success = await self.api.stop()
state = IndevoltRealtimeState.STANDBY
if not success:
raise HomeAssistantError(
@@ -172,7 +184,15 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_key="failed_to_execute_realtime_action",
)
await self.async_request_refresh()
self.async_set_updated_data(
{
**self.data,
IndevoltConfig.READ_ENERGY_MODE: IndevoltEnergyMode.REAL_TIME_CONTROL,
IndevoltConfig.READ_REALTIME_STATE: state,
IndevoltConfig.READ_REALTIME_TARGET_SOC: target_soc,
IndevoltConfig.READ_REALTIME_POWER_LIMIT: power,
}
)
def get_emergency_soc(self) -> int:
"""Get the emergency SOC value."""
+3 -1
View File
@@ -136,7 +136,9 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, int_value
)
else:
raise HomeAssistantError(
+3 -1
View File
@@ -106,7 +106,9 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
)
if success:
await self.coordinator.async_request_refresh()
self.coordinator.async_optimistic_update(
self.entity_description.read_key, value
)
else:
raise HomeAssistantError(
+1 -1
View File
@@ -86,7 +86,7 @@ SENSORS: Final = (
),
# Real-time control state
IndevoltSensorEntityDescription(
key=IndevoltConfig.READ_REALTIME_COMMAND,
key=IndevoltConfig.READ_REALTIME_STATE,
translation_key="realtime_command",
state_mapping={1000: "standby", 1001: "charging", 1002: "discharging"},
device_class=SensorDeviceClass.ENUM,
+8 -1
View File
@@ -126,7 +126,14 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
)
if success:
await self.coordinator.async_request_refresh()
read_value = (
self.entity_description.read_on_value
if value
else self.entity_description.read_off_value
)
self.coordinator.async_optimistic_update(
self.entity_description.read_key, read_value
)
else:
raise HomeAssistantError(
@@ -21,6 +21,7 @@ from homeassistant.const import (
CONF_ICON,
CONF_ID,
CONF_NAME,
CONF_OPTIONS,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
@@ -37,8 +38,6 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "input_select"
CONF_INITIAL = "initial"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
SERVICE_SET_OPTIONS = "set_options"
STORAGE_KEY = DOMAIN
+1 -1
View File
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["deepmerge", "pyipp"],
"requirements": ["pyipp==0.17.0"],
"requirements": ["pyipp==0.17.2"],
"zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."]
}

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