Compare commits

..

180 Commits

Author SHA1 Message Date
Mike Degatano 38e739216e Fix hassio test suite after supervisor update check
Tests that set update_available=True on the supervisor_info mock were
blocked by the new early check in async_setup_entry. Fix by:
- Adding supervisor_info to hassio_stubs fixture so test_auth.py tests
  get a properly initialized mock
- Using a side_effect in test_update.py and test_diagnostics.py mock_all
  fixtures to return update_available=False on the first call (the early
  setup check) then fall back to return_value (update_available=True) for
  coordinator refreshes, preserving update entity state assertions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 19:03:21 +00:00
Mike Degatano 04ad0f028e Ensure Supervisor is up to date before hassio setup completes
When running Supervisor alongside core, Supervisor must be up to date
before core can be updated. Some edge cases break this constraint.
Until fixed on the Supervisor side, add protective logic to core: fetch
supervisor info early in async_setup_entry and, if an update is
available with auto_update enabled, trigger the update and raise
ConfigEntryNotReady so setup retries after the update completes. If
auto_update is disabled, log at DEBUG level and proceed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 19:03:21 +00:00
Artur Pragacz f823ef639a Prefix area to entity ID (#170560) 2026-05-19 20:53:26 +02:00
mithomas da4263b95c Fix missing delay and repeat support in LG Netcast remote (#170324)
Co-authored-by: Copilot <copilot@github.com>
2026-05-19 20:49:43 +02:00
Robert Resch 29e2184163 Fix workflow run (#171367) 2026-05-19 20:49:10 +02:00
Robert Resch 816c3ff939 Adjust aw check requirements checks (#171389)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-19 20:48:53 +02:00
karwosts 2348ccc76e Use modern batteries for demo integration (#171376) 2026-05-19 19:23:11 +01:00
Manu 4202686a0d Remove duplicate constants in Mobile App integration (#171379) 2026-05-19 20:17:18 +02:00
dontinelli dd1437f5f2 Remove obsolete local const in slide_local (#171386) 2026-05-19 20:16:09 +02:00
Willem-Jan van Rootselaar 1a1c9d935c Remove duplicate constant in bsblan integration (#171385) 2026-05-19 19:48:17 +02:00
javicalle 4c0e7eb92d Migrate RFLink YAML configuration (ADR0007) (#161822)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-19 19:44:03 +02:00
Josh Gustafson d288645f0e Declare PARALLEL_UPDATES on arcam_fmj platforms (#171151)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:26:57 +02:00
Jan-Philipp Benecke 66aad8d3c5 Fix solaredge tests (#171378) 2026-05-19 18:38:10 +02:00
peteS-UK 89e15b9eae Remove unused ATTR_TIME from squeezebox const.py (#171374) 2026-05-19 18:29:03 +02:00
Nick Haghiri 489b831a4b Use homeassistant.const CONF_PREFIX in backblaze_b2 (#171365) 2026-05-19 18:24:56 +02:00
Manu f1854e1816 Remove duplicate constant in Notifications for Android TV / Fire TV integration (#171377) 2026-05-19 18:18:36 +02:00
Manu 8931ce561c Remove duplicate constant in ntfy integration (#171375) 2026-05-19 18:08:46 +02:00
Petro31 4d19cec214 Replace duplicate constants with homeassistant.const imports in template (#171349)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-19 18:08:20 +02:00
Manu e111678c40 Remove duplicate constant from HTML5 integration (#171373) 2026-05-19 18:07:07 +02:00
Jan Bouwhuis 69de70407b Remove duplicate constants for MQTT (#171359) 2026-05-19 18:05:16 +02:00
Andrew Jackson 64d17521a4 Remove duplicate const in Mastodon (#171357) 2026-05-19 16:38:11 +01:00
Erik Montnemery b52476a37e Remove use of advanced mode from the homekit integration (#171200) 2026-05-19 16:27:58 +02:00
Denis Shulyaka 58c906a2d1 Replace duplicate constants with homeassistant.const imports in anthropic (#171316) 2026-05-19 16:17:15 +02:00
Denis Shulyaka 3b2fa3f5b7 Replace duplicate constants with homeassistant.const imports in openai_conversation (#171348)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-19 16:15:12 +02:00
Paul Bottein 0dae4689cf Use CONF_CODE in Novy Cooker Hood (#171350)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-19 16:15:08 +02:00
Joost Lekkerkerker cd7fe836b0 Fix CI (#171351) 2026-05-19 16:07:27 +02:00
Robert Resch e3bae0dbda Use multistage workflow to run agentic workflow on forks (#171186)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 16:06:14 +02:00
Mika 7cf3cba27b Split SolarEdge power-flow attributes into sensor entities (#170457)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-19 15:42:36 +02:00
Tsvi Mostovicz de70d9ed82 Jewish Calendar: add a calendar entity (#145140)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-05-19 15:40:26 +02:00
Onero-testdev eb0c1700b7 Add support for SwitchBot Lock Vision (Pro) and Lock Pro Wifi (#170470) 2026-05-19 15:36:33 +02:00
Franck Nijhof 6fa5fc77aa Add pylint checker for duplicate homeassistant.const definitions (#170848)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-19 15:10:13 +02:00
Åke Strandberg c705e8ff56 Cleanup miele API timeouts (#171172) 2026-05-19 14:19:01 +02:00
Jan Bouwhuis ee248b536e Fix subscription ID for restored subscriptions (#171130) 2026-05-19 14:08:36 +02:00
Åke Strandberg 7bfd11cf2e Add missing Miele Dishwasher codes (#171175) 2026-05-19 14:01:35 +02:00
iluvdata 2dae262135 Address future error in RepairFlow for Anthropic (#171156) 2026-05-19 14:53:18 +03:00
Aidan Timson 76a463dd50 Bump aiolyric to 2.1.1, Update OAuth URL for lyric (#171181) 2026-05-19 13:16:47 +02:00
epenet 0d83b1cbe8 Handle temperature unit mismatch in Tuya climate (#171183) 2026-05-19 13:16:02 +02:00
Artur Pragacz ae622a7cd4 Fix zwave_js fixture path resolution (#171196) 2026-05-19 13:11:43 +02:00
Przemko92 3f0af1e5b7 Add Compit switch (#164053) 2026-05-19 13:00:39 +02:00
G Johansson 742e63d02c Fix exception handling in command_line notify service (#170709)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-19 12:49:19 +02:00
nopoz 1042ec2964 Bump pyenvisalink to 4.9 (#171125) 2026-05-19 12:03:20 +02:00
Erik Montnemery f4fdd4d58f Adjust device tracker tests (#171178) 2026-05-19 11:52:12 +02:00
iluvdata 3963555b2f Add RepairsFlowResult pylint check (#171145) 2026-05-19 11:35:25 +02:00
Erwin Douna 4f8885b40d Downloader add proper exceptions (#170771)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 11:35:01 +02:00
Robert Resch 3f49877ff1 Use renovate to update go2rtc (#169508) 2026-05-19 10:10:17 +02:00
Erik Montnemery d2bb31d115 Remove useless input validation from cast options flow (#171171) 2026-05-19 10:09:17 +02:00
Denis Shulyaka f499dbf29f Add web fetch tool support for Anthropic (#167405)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-19 09:50:50 +02:00
Keith Roehrenbeck bc0e3dc3be Fix Apple TV keyboard focus binary_sensor missing on cold start (#170360) 2026-05-19 09:38:48 +02:00
Florent Thoumie fb6e6170bf Improve iaqualink 429 handling (#170231) 2026-05-19 09:24:09 +02:00
Mick Vleeshouwer 9e22711874 Fix controls for UpDownGarageDoor4T and additional 4T covers in Overkiz (#171144) 2026-05-19 09:22:31 +02:00
puddly 1982dd9085 Fix ZHA config entries using a URI without a port (#171164) 2026-05-19 09:14:48 +02:00
Adam Katic c32098decd Add quality scale for speedtestdotnet integration (#170782) 2026-05-19 10:06:06 +03:00
Erik Montnemery 2e87750d70 Remove use of advanced mode from the cast integration (#171090) 2026-05-19 08:26:35 +02:00
karwosts 55354770a8 Make energy electric sources nameable (#170658) 2026-05-19 09:22:42 +03:00
epenet d7b63a40db Rename Tuya fixtures (#171169) 2026-05-19 08:22:09 +02:00
Erik Montnemery c80d1ba003 Correct signature of mock class in test_recovery_from_dbus_restart (#171097) 2026-05-19 07:48:12 +02:00
Paulus Schoutsen e675423c3c add /local to no auth sig required urls (#171140)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-05-19 06:55:47 +02:00
Marcos A L M Macedo 11cbf91563 Add total production sensor support for Tuya SPM02 devices (#171166) 2026-05-19 06:51:46 +02:00
yemua 4d5c36a3c1 Enable current/power/voltage sensors by default for Tuya electrical categories (#171098) 2026-05-19 06:45:58 +02:00
Carlos Sánchez López cc335a3bd9 Add number support for Tuya WG2 alarm panel (Duosmart C30) (#165836)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-05-19 06:38:12 +02:00
Josh Gustafson f764a32564 Use device name in arcam_fmj browse media root (#171160)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:49:22 -04:00
Josh Gustafson aeb7109708 Share arcam_fmj convert_exception decorator from entity module (#171162)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:01:28 -04:00
Josh Gustafson f75c205c08 Annotate parametrized arcam_fmj media_player test signatures (#171163)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:57:24 -04:00
Joost Lekkerkerker e20f4c8f6e Use subentry helper in Satel Integra (#167060)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-19 01:08:53 +02:00
Erik Montnemery 72f6c38e7d Remove use of advanced mode from the tasmota integration (#171093) 2026-05-19 00:43:08 +02:00
Erik Montnemery 40408def0f Don't set _attr_source_type in victron_gx device tracker entity (#171077) 2026-05-19 00:40:05 +02:00
Robert Resch 282737e3c4 Bump gh aw to 0.74.4 (#171137) 2026-05-18 23:02:56 +01:00
Josh Gustafson a1cc735337 Report unknown state in arcam_fmj when power state is unreported (#171149)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:01:51 +01:00
Mick Vleeshouwer b6f4551a76 Add light entity tests to Overkiz (#171102) 2026-05-18 23:46:15 +02:00
Phil-Rad f5d2aa9c12 Use runtime_data and validate connection at setup for dnsip (#169745) 2026-05-18 23:24:57 +02:00
Onero-testdev 612dbf2d44 Add SwitchBot Permanent Outdoor Light support (#170463)
Co-authored-by: Fan Kai <fankai@onero.com>
2026-05-18 23:22:56 +02:00
Maciej Bieniek f2691e4feb Change model to model ID in the Tractive DeviceInfo (#171147) 2026-05-18 23:12:24 +02:00
Thomas D f9654e15a6 Support stepper output in Qbus integration (#170772) 2026-05-18 22:44:16 +02:00
Michael 01dde25ffa Bump aioimmich to 0.14.1 (#171138) 2026-05-18 22:25:51 +02:00
Michael 34254c138f Fix handling of tracked devices on cleanup in FRITZ!Box Tools (#170574)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-18 22:03:43 +02:00
renovate[bot] 1076d65c9c Update syrupy to 5.2.0 (#171100)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 22:00:11 +02:00
Heikki Henriksen ad71e31bad prusalink: add sd_ready, farm_mode, and status_connect binary sensors (#169310)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:56:52 +02:00
Franck Nijhof 7608d5f99d Fix WeatherFlow websocket crash when data payload is None (#171037) 2026-05-18 15:43:42 -04:00
Erik Montnemery cafcbf8179 Improve bluetooth test fixture (#171061) 2026-05-18 21:17:50 +02:00
Erik Montnemery 852faa7f95 Fix docstring of cv.string (#171128) 2026-05-18 21:14:46 +02:00
renovate[bot] 5cf1e185f0 Update ruff (#171118)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
2026-05-18 20:57:05 +02:00
Glenn Waters c4d25a5a26 ElkM1 integration: Deprecate Elk Setting sensors; replaced by time/number entities (#170041) 2026-05-18 20:56:29 +02:00
Maciej Bieniek 18f8e11865 Split Tractive entities into tracker-related and pet-related (#170256) 2026-05-18 20:55:05 +02:00
Kamil Breguła e8f3d357c4 Add group support to WLED main light (#169669)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 20:44:34 +02:00
Michael Hansen 1ad81697f7 Add chat log and response rendering to Wyoming conversation (#170433) 2026-05-18 20:43:33 +02:00
Arie Catsman f66652c729 Provide request retry option to overcome intermittant enphase_envoy failures (#168222)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-18 20:40:14 +02:00
Robert Resch c468ae77f3 Enable agentic library workflow on forks and users without write rightsA (#171123) 2026-05-18 20:20:36 +02:00
renovate[bot] 251d7e15d2 Update requests to 2.34.2 (#171119) 2026-05-18 20:18:53 +02:00
Sören d268f8b486 Restore Avea brightness on turn on (#171120) 2026-05-18 19:58:59 +02:00
Jamin 6f3dfab487 Voip runtime data (#170765)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 19:40:30 +02:00
Crocmagnon 8d8b9bb2e8 data grand lyon: split coordinators (#170662) 2026-05-18 19:30:55 +02:00
Franck Nijhof 8c9d659dcf Use HA timezone for date in recollect_waste (#171106) 2026-05-18 19:20:14 +02:00
Franck Nijhof f08adfe712 Use HA timezone for date in cookidoo (#171109) 2026-05-18 19:18:30 +02:00
renovate[bot] de29414b37 Update uv to 0.11.14 (#171099)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 19:13:07 +02:00
Ludovic BOUÉ 01d9c2e810 Add siren platform support to Matter integration (#170031)
Co-authored-by: Ludovic BOUÉ <938089+lboue@users.noreply.github.com>
2026-05-18 18:45:28 +02:00
epenet 9b3b3eca6d Prioritize native Tuya unit of measurement (#170338) 2026-05-18 17:29:46 +02:00
Copilot 2e45ce36a7 Create agentic workflow to validate dependencies (#168855)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: edenhaus <26537646+edenhaus@users.noreply.github.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-05-18 16:59:49 +02:00
g4bri3lDev fe56ce6813 Bump py-opendisplay to 7.0.0 (#171088) 2026-05-18 16:50:51 +02:00
Erik Montnemery 8000b419ea Remove stale reference to advanced mode from MQTT tests (#171095) 2026-05-18 16:14:52 +02:00
Noah Husby f0a5ce747e Disallow session closure for Cambridge Audio (#171036) 2026-05-18 15:47:49 +02:00
aide 7da5b10b51 Add new integration for AiDot (#167272)
Co-authored-by: bryan <185078974@qq.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 14:57:25 +02:00
Mick Vleeshouwer 94b373641d Fix tilt and position support for VenetianBlind covers in Overkiz (#170974) 2026-05-18 14:51:26 +02:00
Pete Sage dfd241dd1a Add search to Sonos (#170891)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:39:12 +02:00
Klaas Schoute 27b161bf7c Add new params to actions of easyEnergy integration (#169225)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:36:01 +02:00
Josef Zweck f2362aa2a3 Bump pylamarzocco to 2.2.5 (#171083) 2026-05-18 14:16:04 +02:00
Matthias Alphart 90946c3e2f Fix swallowed exception in knx event_register action (#171010)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 14:06:25 +02:00
Franck Nijhof 318091689c Fix line length violations in new code since cleanup PRs (#171062) 2026-05-18 14:03:52 +02:00
Petro31 ee8c3ca864 Fix swallowed exceptions in template action handlers (#171080) 2026-05-18 13:55:59 +02:00
Jonathan Segev 5f6f300a20 Bump aiolyric to 2.1.0 (#171007)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-18 13:54:33 +02:00
Sören ad04aeced9 Fix Avea color state refresh (#171003) 2026-05-18 13:54:08 +02:00
Franck Nijhof bbb31f2910 Group sequential executor jobs in verisure config flow (#171081) 2026-05-18 13:47:58 +02:00
Martin Hjelmare 0ed81e426b Fix swallowed exceptions in VLC Telnet actions (#171071) 2026-05-18 13:42:12 +02:00
Mick Vleeshouwer 4582c56c1c Fix is_closed state and position for DynamicPergola covers in Overkiz (#170983) 2026-05-18 13:41:28 +02:00
Mick Vleeshouwer 9ce3e00e87 Fix is_closed state for DiscretePositionableGarageDoor in Overkiz (#170981) 2026-05-18 13:40:48 +02:00
Franck Nijhof bd2ea9a148 Group sequential executor jobs in roomba vacuum (#171078) 2026-05-18 13:39:00 +02:00
Paulus Schoutsen e34be91439 Bump dsmr-parser to 1.7.0 (#171082)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 13:36:32 +02:00
Franck Nijhof 3e5beb9aa3 Group sequential executor jobs in ezviz config flow (#171084) 2026-05-18 13:33:11 +02:00
Franck Nijhof ac5df83d1a Group sequential executor jobs in comfoconnect fan (#171085) 2026-05-18 13:30:49 +02:00
Franck Nijhof c9e014c5d8 Group sequential executor jobs in soma setup (#171087) 2026-05-18 13:29:42 +02:00
Franck Nijhof 1b7564dcdf Group sequential executor jobs in smappee config flow (#171086) 2026-05-18 13:29:00 +02:00
Paulus Schoutsen 71425dd19f Add buttons platform to Marantz IR Remote (PM6006) (#169627)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 12:46:09 +02:00
zhangluofeng eea08a0457 Add Xthings Cloud Switch (#170554) 2026-05-18 12:45:04 +02:00
Erik Montnemery 00132b4416 Remove source_type property from paj_gps device tracker entity (#171076) 2026-05-18 12:43:48 +02:00
Erik Montnemery 6b9efed899 Don't set _attr_source_type in nrgkick device tracker entity (#171075) 2026-05-18 12:43:39 +02:00
Erik Montnemery b0b6b46152 Remove source_type property from lojack device tracker entity (#171073) 2026-05-18 12:43:33 +02:00
Erik Montnemery 044ef25cb6 Remove source_type property from fressnapf_tracker device tracker entity (#171072) 2026-05-18 12:43:30 +02:00
bkobus-bbx b633fbcf07 Fix ValueError when turning on blebox light with brightness set to 0 (#170769)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-18 12:41:30 +02:00
Mick Vleeshouwer 7c9b6ad2a8 Fix controls for OpenCloseGate4T (rts:GateOpenerRTS4TComponent) in Overkiz (#170987) 2026-05-18 12:39:48 +02:00
Jan-Philipp Benecke 89d9fff1e9 Fix typo in lovelace action error message (#171074) 2026-05-18 13:38:27 +03:00
A. Gideonse e0af3dfa99 Add real-time control sensors to Indevolt (#170729)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 12:32:37 +02:00
renovate[bot] 4fb3ad102c Update cryptography to 48.0.0 (#170372) 2026-05-18 12:32:35 +02:00
Øyvind Matheson Wergeland dc2ab012fa End nobo_hub config flow tests in CREATE_ENTRY or ABORT (#170141) 2026-05-18 12:30:52 +02:00
Dougal Matthews 140fef6915 Add geo_location entity support to Prometheus exporter (#170721) 2026-05-18 12:27:41 +02:00
Franck Nijhof 822a567ca9 Return media_content_id as string in forked_daapd (#171059) 2026-05-18 12:26:45 +02:00
Sören aa8904b0cd Use config entry title for Avea light (#170978) 2026-05-18 12:26:09 +02:00
Franck Nijhof e9f9194b7b Fix swallowed exception in cast play_media for unsupported apps (#171064) 2026-05-18 12:22:22 +02:00
Jan-Philipp Benecke d0f4cba32c Reraise HomeAssistantError with translation in lovelace (#171053) 2026-05-18 11:53:22 +02:00
Erik Montnemery beba530a9a Remove source_type from autoskope device tracker entity (#171070) 2026-05-18 11:47:11 +02:00
AlCalzone 5d3fd5a487 Bump opensensemap-api to 0.4.1 (#171056) 2026-05-18 11:42:10 +02:00
Franck Nijhof bed6af2ef2 Fix swallowed exceptions in rest switch action handlers (#171069) 2026-05-18 11:38:06 +02:00
Mick Vleeshouwer 2b20b69928 Add tests for scene platform in Overkiz (#170993) 2026-05-18 11:35:33 +02:00
Jan Bouwhuis d5d50ac11a Set subscription identifier to allow matching duplicate payloads with overlapping subscriptions (#169604)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-18 11:27:05 +02:00
Mick Vleeshouwer ba5a62ec2a Replace redacted labels in test fixtures with meaningful names in overkiz (#170988) 2026-05-18 11:19:29 +02:00
Joakim Plate 88ca0faea0 Require service on fjaraskupan to detect it (#170363) 2026-05-18 11:00:05 +02:00
LG-ThinQ-Integration a333f31d44 Fix swallowed exceptions in lg_thinq action handlers (#171047)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-05-18 10:09:30 +02:00
James Nimmo 8854ad5765 Bump pyIntesishome to 1.8.8 (#171041) 2026-05-18 09:51:42 +02:00
Franck Nijhof 0eecb03b84 Add stop command to Overkiz pergola horizontal awning covers (#171034) 2026-05-18 08:57:44 +02:00
Mick Vleeshouwer 0c22c13b1f Add additional overrides to cover entity in Overkiz (#171019) 2026-05-18 08:49:37 +02:00
renovate[bot] bf56fad3f9 Update uv to 0.11.13 (#171048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 08:15:34 +02:00
Paulus Schoutsen 078d40ac54 Bump serialx to 1.8.0 (#171043)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 08:08:01 +02:00
Daniel Hjelseth Høyer 1b7bda06d3 Bump pyTibber to 0.37.6 (#170393)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-05-18 07:54:45 +02:00
renovate[bot] 828dde26e5 Update coverage to 7.14.0 (#171042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 22:38:10 -04:00
Nick Haghiri e8d21e57b3 Group sequential executor jobs in Backblaze B2 backup agent (#171045) 2026-05-17 22:37:41 -04:00
Noah Husby 481965eb0d Bump aiostreammagic to 2.13.1 (#171035) 2026-05-17 20:59:48 -04:00
Alex Taylor 2fcfa8320f Pin decorator to avoid license metadata regression (#171038) 2026-05-17 20:17:31 -04:00
Christian Lackas 95c68da115 Bump homematicip to 2.12.0 (#170968) 2026-05-17 17:47:50 -04:00
Ronald van der Meer d547076033 Bump python-duco-connectivity to 0.5.0 (#170989) 2026-05-17 17:46:43 -04:00
Franck Nijhof db0006c100 Fix shorthand template conditions in choose blocks crashing all automations (#171018) 2026-05-17 23:25:37 +02:00
Paulus Schoutsen f8d4826bf3 Send Marantz IR power-on command with repeat_count=5 (#171032)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 17:24:07 -04:00
Franck Nijhof 88f6b7159a Fix line length violations in tests/components j-l (#170961)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:16:11 -04:00
Franck Nijhof f7faed7330 Use timezone-aware date in SolarEdge energy details coordinator (#170969)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:42 -04:00
Franck Nijhof 302148b078 Fix line length violations in tests/components p-r (#170970) 2026-05-17 17:14:47 -04:00
Franck Nijhof 5b2816e56c Fix line length violations in tests/components s (#170990)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-05-17 17:14:26 -04:00
Franck Nijhof f7cf279648 Fix time trigger crash when using entity_id dict format without offset (#171006)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2026-05-17 17:13:32 -04:00
Franck Nijhof ee83a14391 Prevent Google Assistant entity sync from blocking startup (#170991)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:13:15 -04:00
Franck Nijhof 833ff982d0 Fix line length violations in tests/components t-z (#170994) 2026-05-17 17:12:29 -04:00
Paulus Schoutsen d8cb3ab4b8 Mount MariaDB/MySQL data directory on tmpfs in CI (#170915) 2026-05-17 17:06:28 -04:00
Paulus Schoutsen 23b0f550b1 Fix flaky homekit test_reload port check timeout (#171029)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-17 17:06:17 -04:00
Franck Nijhof c66eeed8f8 Use timezone-aware date in Ridwell pickup event filtering (#171001)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 17:05:54 -04:00
Franck Nijhof bdc9d881ea Load template extensions by class to prevent import deadlock (#170995)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 16:55:54 -04:00
Franck Nijhof 95e2f5e219 Use asyncio.get_running_loop() in emulated_hue UPnP responder (#171000)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-17 16:53:11 -04:00
Franck Nijhof 68fc5c0e87 Include entity ID and URL in REST switch error logs (#171008) 2026-05-17 16:49:19 -04:00
Franck Nijhof 67c1930c6f Fix threshold preview crash when hysteresis is not provided (#171009) 2026-05-17 16:48:36 -04:00
Franck Nijhof c90017d207 Fix Growatt mix device IndexError when chart data is empty (#171012) 2026-05-17 16:47:59 -04:00
Franck Nijhof 9dce6943de Fix SleepIQ timer units: seconds should be minutes for core climate and foot warmer (#171013) 2026-05-17 16:45:55 -04:00
Franck Nijhof 6a5faf2ec7 Fix Control4 climate crash when humidity is 'Undefined' (#171015) 2026-05-17 16:45:09 -04:00
Franck Nijhof d0711624c0 Fix manual alarm panel crash on restore with invalid state (#171016) 2026-05-17 16:44:39 -04:00
Franck Nijhof 03ea95dfd4 Handle Daikin connection errors gracefully in coordinator (#171017) 2026-05-17 16:44:02 -04:00
Franck Nijhof 721c736c03 Allow stop action with error: false and response_variable (#171020) 2026-05-17 16:42:27 -04:00
Franck Nijhof 1c105a5766 Fix Verisure alarm crash when cloud rejects arm/disarm command (#171024) 2026-05-17 16:41:26 -04:00
1484 changed files with 36608 additions and 14063 deletions
+1 -1
View File
@@ -14,7 +14,6 @@ Dockerfile.dev linguist-language=Dockerfile
# Generated files
CODEOWNERS linguist-generated=true
Dockerfile linguist-generated=true
homeassistant/generated/*.py linguist-generated=true
machine/* linguist-generated=true
mypy.ini linguist-generated=true
@@ -23,3 +22,4 @@ requirements_all.txt linguist-generated=true
requirements_test_all.txt linguist-generated=true
requirements_test_pre_commit.txt linguist-generated=true
script/hassfest/docker/Dockerfile linguist-generated=true
.github/workflows/*.lock.yml linguist-generated=true
+3
View File
@@ -11,3 +11,6 @@ updates:
- github_actions
cooldown:
default-days: 7
ignore:
# Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
- dependency-name: "github/gh-aw-actions/**"
+26
View File
@@ -6,6 +6,7 @@
"pep621",
"pip_requirements",
"pre-commit",
"dockerfile",
"custom.regex",
"homeassistant-manifest"
],
@@ -21,6 +22,10 @@
]
},
"dockerfile": {
"managerFilePatterns": ["/^Dockerfile$/"]
},
"homeassistant-manifest": {
"managerFilePatterns": [
"/^homeassistant/components/[^/]+/manifest\\.json$/"
@@ -35,6 +40,14 @@
"matchStrings": ["required-version = \">=(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ruff",
"datasourceTemplate": "pypi"
},
{
"customType": "regex",
"description": "Update go2rtc RECOMMENDED_VERSION in const.py alongside the Dockerfile pin",
"managerFilePatterns": ["/^homeassistant/components/go2rtc/const\\.py$/"],
"matchStrings": ["RECOMMENDED_VERSION = \"(?<currentValue>[\\d.]+)\""],
"depNameTemplate": "ghcr.io/alexxit/go2rtc",
"datasourceTemplate": "docker"
}
],
@@ -184,6 +197,13 @@
"enabled": true,
"labels": ["dependency"]
},
{
"description": "Docker allowlist (ghcr.io exposes no release timestamps so the global cooldown needs to be bypassed)",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"enabled": true,
"minimumReleaseAge": null,
"labels": ["dependency"]
},
{
"description": "Group ruff pre-commit hook with its PyPI twin into one PR",
"matchPackageNames": ["astral-sh/ruff-pre-commit", "ruff"],
@@ -213,6 +233,12 @@
"matchPackageNames": ["pylint", "astroid"],
"groupName": "pylint",
"groupSlug": "pylint"
},
{
"description": "Group go2rtc Dockerfile pin with const.py RECOMMENDED_VERSION into one PR",
"matchPackageNames": ["ghcr.io/alexxit/go2rtc"],
"groupName": "go2rtc",
"groupSlug": "go2rtc"
}
]
}
@@ -0,0 +1,31 @@
name: Check requirements (changes detection)
# Stage 1 of the agentic Check requirements workflow.
# Just kicks off Stage 2 (`check-requirements-dispatcher.yml`) which starts the agentic workflow
# yamllint disable-line rule:truthy
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "requirements*.txt"
- "homeassistant/package_constraints.txt"
- "pyproject.toml"
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
changes:
name: Requirements files changed
runs-on: ubuntu-latest
timeout-minutes: 1
steps:
- name: Record PR number
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |-
echo "Requirements files changed in PR #${PR_NUMBER}"
@@ -0,0 +1,73 @@
name: Check requirements (dispatcher)
# Stage 2 of the agentic Check requirements workflow. Runs on completion of
# stage 1 (`check-requirements-changes.yml`) and dispatches stage 3
# (`check-requirements.lock.yml`)
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
# yamllint disable-line rule:truthy
on: # zizmor: ignore[dangerous-triggers]
# workflow_run is safe here: this workflow does not check out PR code or run
# any code from the triggering PR. It only resolves the PR number from the
# head SHA and dispatches `check-requirements.lock.yml` with that number as
# a sanitized string input. The PR code is analysed downstream in the
# agentic workflow (`check-requirements.lock.yml`)
workflow_run:
workflows: ["Check requirements (changes detection)"]
types: [completed]
permissions: {}
jobs:
dispatch:
name: Dispatch agentic requirements check
if: >
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write # For triggering the downstream workflow
pull-requests: read # For querying PRs by commit SHA
steps:
- name: Resolve PR number from head SHA and trigger agentic requirements check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepository = context.payload.workflow_run.head_repository;
const headRepo = headRepository.full_name;
// Query the head repository (which may be a fork). When the PR comes
// from a fork, the upstream's listPullRequestsAssociatedWithCommit
// returns no results for the fork's commit SHA.
const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: headRepository.owner.login,
repo: headRepository.name,
commit_sha: headSha,
});
const matches = pulls.filter(p =>
p.state === 'open'
&& p.head.ref === headBranch
&& p.head.repo?.full_name === headRepo
);
if (matches.length === 0) {
core.info(`No open PR found for head SHA ${headSha} on ${headRepo}:${headBranch}; nothing to dispatch.`);
return;
}
const defaultBranch = context.payload.workflow_run.repository.default_branch;
for (const pr of matches) {
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'check-requirements.lock.yml',
ref: defaultBranch,
inputs: {
pull_request_number: String(pr.number),
},
});
core.info(`Dispatched check-requirements.lock.yml for PR #${pr.number}.`);
}
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
---
on:
workflow_dispatch:
inputs:
pull_request_number:
description: "Pull request number to (re-)check"
required: true
type: number
permissions:
contents: read
pull-requests: read
issues: read
network:
allowed:
- python
tools:
web-fetch: {}
github:
toolsets: [default]
min-integrity: unapproved
safe-outputs:
add-comment:
max: 1
target: ${{ inputs.pull_request_number }}
concurrency:
group: ${{ github.workflow }}-${{ inputs.pull_request_number }}
cancel-in-progress: true
post-steps:
- name: Verify agent produced an add_comment safe-output
if: always()
run: |
OUTPUT=/tmp/gh-aw/agent_output.json
if [ ! -f "${OUTPUT}" ]; then
echo "::error::Agent output file ${OUTPUT} is missing; the agent did not run to completion."
exit 1
fi
if ! grep -q '"add_comment"' "${OUTPUT}"; then
echo "::error::Agent did not emit an add_comment safe-output; no review comment was posted to the PR."
echo "Agent output:"
cat "${OUTPUT}"
exit 1
fi
description: >
Checks changed Python package requirements on PRs targeting the core repo
(including PRs opened from forks) and verifies licenses match PyPI metadata, source
repositories are publicly accessible, PyPI releases were uploaded via
automated CI (Trusted Publisher attestation), the package's release pipeline
uses OIDC or equivalent automated credentials (not static tokens), and the PR
description contains the required links.
---
# Check requirements
You are a code review assistant for the Home Assistant project. Your job is to
review changes to Python package requirements and verify they meet the project's
standards.
## Context
- Home Assistant uses `requirements_all.txt` (all integration packages),
`requirements.txt` (core packages), `requirements_test.txt` (test
dependencies), and `requirements_test_all.txt` (all test dependencies) to
declare Python dependencies.
- Each integration lists its packages in `homeassistant/components/<name>/manifest.json`
under the `requirements` field.
- Allowed licenses are maintained in `script/licenses.py` under
`OSI_APPROVED_LICENSES_SPDX` (SPDX identifiers) and `OSI_APPROVED_LICENSES`
(classifier strings).
## Step 1 — Identify Changed Packages
This workflow is triggered via `workflow_dispatch`. The PR number to check is
**#${{ inputs.pull_request_number }}**. Use that PR number for **every** GitHub
API call in the steps below (fetching the diff, the PR body, etc.). Do **not**
rely on `github.event.pull_request` — it is not populated for
`workflow_dispatch` runs.
Use the GitHub tool to fetch the PR diff for that PR number. Look for
lines that were added (`+`) or removed (`-`) in **any** of these files:
- `requirements.txt`
- `requirements_all.txt`
- `requirements_test.txt`
- `requirements_test_all.txt`
- `homeassistant/package_constraints.txt`
- `pyproject.toml`
For each changed line that contains a package pin (e.g. `SomePackage==1.2.3`),
classify it as:
- **New package**: the package name appears only in `+` lines, with no
corresponding `-` line for the same package name.
- **Version bump**: the same package name appears in both `+` lines (new
version) and `-` lines (old version), with different version numbers.
Record the **old version** and **new version** for every version bump — you
will need these values in Step 4.
## Step 2 — Check License via PyPI
For each new or bumped package:
1. Fetch `https://pypi.org/pypi/{package_name}/json` (use the exact
package name as it appears on the requirements file).
2. From the JSON response, extract:
- `info.license` — free-text license field
- `info.license_expression` — SPDX expression (if present)
- `info.classifiers` — filter for entries starting with `"License ::"`,
then normalize each match the same way as `script/licenses.py` by
extracting the final ` :: ` segment (for example,
`"License :: OSI Approved :: MIT License"``"MIT License"`).
3. Determine if the license is in the approved list from `script/licenses.py`:
- SPDX identifiers: compare against `OSI_APPROVED_LICENSES_SPDX`
- Normalized classifier strings: compare against `OSI_APPROVED_LICENSES`
4. Flag a package as ❌ if the license is unknown, missing, or not in the
approved list. Flag as ⚠️ if the license information is ambiguous or cannot
be definitively determined.
## Step 2b — Verify PyPI Release Was Uploaded by CI
For each new or bumped package, verify that the release on PyPI was published
automatically by a CI pipeline (via OIDC Trusted Publisher), not uploaded
manually.
1. Fetch the PyPI JSON for the specific version being introduced or bumped:
`https://pypi.org/pypi/{package_name}/{version}/json`
2. Inspect the `urls` array in the response. For each distribution file (wheel
or sdist), note the filename.
3. For each filename, attempt to fetch the PyPI provenance attestation:
`https://pypi.org/integrity/{package_name}/{version}/{filename}/provenance`
- If the response is HTTP 200 and contains a valid attestation object,
inspect `attestation_bundles[*].publisher`. A Trusted Publisher attestation
will have a `kind` identifying the CI system (e.g. `"GitHub Actions"`,
`"GitLab"`) and a `repository` or `project` field matching the source
repository.
- If at least one distribution file has a valid Trusted Publisher attestation,
mark ✅ CI-uploaded.
- If no attestation is found for any file (404 for all), mark ⚠️ — "Release
has no provenance attestation; it may have been uploaded manually".
- If an attestation exists but the `publisher` does not identify a recognized
CI system or Trusted Publisher, mark ⚠️ — "Attestation present but
publisher cannot be verified as automated CI".
Note: if PyPI returns an error fetching the per-version JSON, fall back to the
latest JSON (`https://pypi.org/pypi/{package_name}/json`) and look up the
specific version in the `releases` dict.
## Step 3 — Identify Repository URL
For each new or bumped package:
1. From the PyPI JSON at `info.project_urls`, find the source repository URL
(keys such as `"Source"`, `"Homepage"`, `"Repository"`, or `"Source Code"`).
2. Record that repository URL for later checks.
3. If no suitable repository URL is present, mark ❌ with a note that the
source repository URL is missing and cannot be verified.
## Step 4 — Check PR Description
Read the PR body from the GitHub API for PR
#${{ inputs.pull_request_number }}. Extract all URLs present in the PR body.
### 4a — New packages: repository link required
For **new packages** (brand-new dependency not previously in any requirements
file): the PR description must contain a link that points to the package's
**source repository** as identified in Step 3 (the URL recorded from
`info.project_urls`). A PyPI page link alone is **not** acceptable — the link
must point directly to the source repository (e.g. a GitHub or GitLab URL).
- If a URL in the PR body matches (or is a sub-path of) the source repository
URL identified via PyPI, mark ✅.
- If the PR body contains a source repository URL that does **not** match the
repository URL found in the package's PyPI metadata (`info.project_urls`),
mark ❌ — "PR description links to `<pr_url>` but PyPI reports the source
repository as `<pypi_repo_url>`; please use the correct repository URL."
- If no source repository URL is present in the PR body at all, mark ❌ —
"PR description must link to the source repository at `<repo_url>` (found
via PyPI). A PyPI page link is not sufficient."
### 4b — Version bumps: changelog or diff link matching the bump
For **version bumps**: the PR description must contain a link to a changelog,
release notes page, or a diff/comparison URL that references the **exact
versions** being bumped (old → new) as recorded in the diff from Step 1.
Checks to perform for each bumped package (old version = X, new version = Y):
1. Extract all URLs from the PR body that contain the repository's domain or
path (as identified in Step 3).
2. Verify that at least one such URL includes both the old version (X) and the
new version (Y) in some form — e.g. a GitHub compare URL like
`compare/vX...vY`, a releases URL mentioning version Y, or a
`CHANGELOG.md` anchor referencing Y.
3. Confirm the link's version range matches the actual bump in the diff. If
the link references versions different from X → Y (for example, the PR
bumps `1.2.3 → 1.3.0` but the link points to `compare/v1.2.0...v1.2.4`),
the link does not match the bump.
Outcome:
- ✅ — a URL pointing to the correct repo with version references that match
the exact bump (X → Y).
- ❌ — no changelog/diff link is found, or the link does not match the actual
bump (X → Y). Explain what was found and what is expected.
## Step 5 — Verify Source Repository is Publicly Accessible
Before inspecting the release pipeline, confirm that the source repository
identified in Step 3 is publicly reachable.
For each new or bumped package:
1. Use the source repository URL recorded in Step 3.
2. If no repository URL was found in `info.project_urls`, mark ❌ — "No source
repository URL found in PyPI metadata; a public source repository is
required."
3. If a repository URL was found, perform a GET request to that URL (using
web-fetch). If the response is HTTP 200 and returns a publicly accessible
page (not a login redirect or error page), mark ✅.
4. If the response is non-200, the URL redirects to a login/authentication page,
or the repository appears private or unavailable, mark ❌ — "Source
repository at `<repo_url>` is not publicly accessible. Home Assistant
requires all dependencies to have publicly available source code." **Do not
proceed with the release pipeline check (Step 6) for this package.**
## Step 6 — Check Release Pipeline Sanity
For each new or bumped package, determine the source repository host from the
URL identified in Step 3, then inspect whether the project's release/publish CI
workflow is sane. The checks differ by hosting provider.
### GitHub repositories (`github.com`)
1. Using the GitHub API, list the workflows in the source repository:
`GET /repos/{owner}/{repo}/actions/workflows`
2. Identify any workflow whose name or filename suggests publishing to PyPI
(e.g., contains "release", "publish", "pypi", or "deploy").
3. Fetch the workflow file content and check the following:
a. **Trigger sanity**: The publish job should be triggered by `push` to tags,
`release: published`, or `workflow_run` on a release job — **not** solely
by `workflow_dispatch` with no additional guards. A `workflow_dispatch`
trigger alongside other triggers is acceptable. Mark ❌ if the only trigger
is manual `workflow_dispatch` with no environment protection rules.
b. **OIDC / Trusted Publisher**: The workflow should use OIDC-based publishing.
Look for `id-token: write` permission and one of:
- `pypa/gh-action-pypi-publish` action
- `actions/attest-build-provenance` action
- Any step that sets `TWINE_PASSWORD` from `secrets.PYPI_TOKEN` directly
(treat this as a static long-lived API token rather than OIDC).
Mark ✅ if OIDC is used, ⚠️ if the publish method cannot be determined.
If a static secret token is the only credential, mark ⚠️ for version
bumps (the package was already accepted at a previous version; suggest
the upstream maintainer switch to OIDC / Trusted Publisher for better
security) and ❌ for new packages.
c. **No manual upload bypass**: Verify there is no step that calls
`twine upload` or `pip upload` outside of a properly gated job (e.g., one
that requires an environment approval). Flag ⚠️ if such steps exist.
4. If no publish workflow is found in the repository, mark ⚠️ — "No publish
workflow found; it is unclear how this package is released to PyPI."
### GitLab repositories (`gitlab.com` or self-hosted GitLab)
1. Use the GitLab REST API to list CI/CD pipeline configuration files. First
resolve the project ID via
`GET https://gitlab.com/api/v4/projects/{url-encoded-namespace-and-name}`
and note the `id` field.
2. Fetch the repository's `.gitlab-ci.yml` (and any included files) using
`GET https://gitlab.com/api/v4/projects/{id}/repository/files/.gitlab-ci.yml/raw?ref=HEAD`
(use web-fetch for public repos).
3. Identify any job whose name or `stage` suggests publishing to PyPI
(e.g., "publish", "deploy", "release", "pypi").
4. For each such job, check:
a. **Trigger sanity**: The job should run only on tag pipelines (`only: tags`
or `rules: - if: $CI_COMMIT_TAG`) or on protected branches — **not**
solely on manual triggers (`when: manual`) with no additional protection.
Mark ❌ if the only trigger is manual with no environment or protected-branch
guard.
b. **Automated credentials**: The job should use GitLab's OIDC ID token
(`id_tokens:` block) and `pypa/gh-action-pypi-publish` equivalent, or
reference `secrets.PYPI_TOKEN` / `$PYPI_TOKEN` injected from GitLab CI/CD
protected variables. Flag ❌ if the token is hard-coded or unprotected.
Mark ✅ if OIDC is used, ⚠️ if the method cannot be determined. If a
protected static token is the only credential, mark ⚠️ for version bumps
(suggest the upstream maintainer switch to OIDC / Trusted Publisher for
better security) and ❌ for new packages.
c. **No manual upload bypass**: Flag ⚠️ if any job calls `twine upload`
without being behind a protected-variable or environment guard.
5. If no publish job is found, mark ⚠️ — "No publish job found in .gitlab-ci.yml;
it is unclear how this package is released to PyPI."
### Other code hosting providers
For repositories hosted on platforms other than GitHub or GitLab (e.g.,
Bitbucket, Codeberg, Gitea, Sourcehut):
1. Use web-fetch to retrieve the repository's root page and look for any
publicly visible CI configuration files (e.g., `.circleci/config.yml`,
`Jenkinsfile`, `azure-pipelines.yml`, `bitbucket-pipelines.yml`,
`.builds/*.yml` for Sourcehut).
2. Apply the same conceptual checks as above:
- Does publishing run on automated triggers (tags/releases), not solely
manual ones?
- Are credentials injected by the CI system (not hard-coded)?
- Is there a `twine upload` or equivalent step that could be run manually?
3. If no CI configuration can be retrieved, mark ⚠️ — "Release pipeline could
not be inspected; hosting provider is not GitHub or GitLab."
## Step 7 — Post a Review Comment
**Always** post a review comment using `add_comment`, regardless of whether
packages pass or fail. Use the following structure:
**Note on deduplication**: The workflow automatically updates any previous
requirements-check comment on the PR in place (preserving its position in the
thread). If no previous comment exists, the newly created comment is kept as-is.
You do not need to search for or update previous comments yourself.
### Comment structure
Begin every comment with the HTML marker `<!-- requirements-check -->` on its
own line (this is used by the workflow to find the previous comment and update
it on the next run).
### 7a — Overall summary line
Begin the comment with a single summary line, before anything else:
- If everything passed: `All requirements checks passed. ✅`
- If there are failures or warnings: `⚠️ Some checks require attention — see the details below.`
### 7b — Summary table
Render a compact table where every check column contains **only the status
icon** (✅, ⚠️, or ❌). No explanatory text belongs inside the table cells —
all detail goes in the per-package sections below.
Use `—` (em dash) when a check was skipped (e.g. Release Pipeline is skipped
when the repository is not publicly accessible).
```
<!-- requirements-check -->
## Check requirements
| Package | Type | Old→New | License | Repo Public | CI Upload | Release Pipeline | PR Link |
|---------|------|---------|---------|-------------|-----------|------------------|---------|
| PackageA | bump | 1.2.3→1.3.0 | ✅ | ✅ | ✅ | ✅ | ✅ |
| PackageB | new | —→4.5.6 | ❌ | ✅ | ⚠️ | ⚠️ | ❌ |
| PackageC | bump | 2.0.0→2.1.0 | ✅ | ❌ | — | — | ❌ |
```
### 7c — Per-package detail sections
After the table, add one collapsible `<details>` block per package.
- If **all checks passed** for that package, render the block **collapsed**
(no `open` attribute) so the comment stays concise.
- If **any check failed or produced a warning**, render the block **open**
(`<details open>`) so the contributor sees the issues immediately.
Each block must include the full detail for every check: the license found, the
repository URL, whether a provenance attestation was found, the release
pipeline findings, and the PR link found (or missing, or mismatched with the
actual bump). For failed or warned checks, explain exactly what the contributor
must fix, including the expected source repository URL, expected version range,
etc.
Template (repeat for each package):
```
<details open>
<summary><strong>PackageB 📦 new —→4.5.6</strong></summary>
- **License**: ❌ License is `UNKNOWN` — not in the approved list. Check PyPI metadata and `script/licenses.py`.
- **Repository Public**: ✅ https://github.com/example/packageb is publicly accessible.
- **CI Upload**: ⚠️ No provenance attestation found for any distribution file. The release may have been uploaded manually.
- **Release Pipeline**: ⚠️ No publish workflow found in the repository; it is unclear how this package is released to PyPI.
- **PR Link**: ❌ PR description must link to the source repository at https://github.com/example/packageb (a PyPI page link is not sufficient).
</details>
```
Collapsed example (all checks passed):
```
<details>
<summary><strong>PackageA 📦 bump 1.2.3→1.3.0</strong></summary>
- **License**: ✅ MIT
- **Repository Public**: ✅ https://github.com/example/packagea
- **CI Upload**: ✅ Trusted Publisher attestation found (GitHub Actions).
- **Release Pipeline**: ✅ OIDC via `pypa/gh-action-pypi-publish`; triggered on `release: published`; `environment: release` gate.
- **PR Link**: ✅ https://github.com/example/packagea/compare/v1.2.3...v1.3.0
</details>
```
## Notes
- Be constructive and helpful. Provide direct links where possible so the
contributor can quickly fix the issue.
- If PyPI returns an error for a package, mention that it could not be found and
suggest the contributor verify the package name.
- For packages that only appear in `homeassistant/package_constraints.txt` or
`pyproject.toml` without being tied to a specific integration, the PR
description link requirement still applies.
- When checking test-only packages (from `requirements_test.txt` or
`requirements_test_all.txt`), apply the same license, repository, and PR
description checks as for production dependencies.
- A package that appears in both a production file and a test file should only
be reported once; use the production file entry as the canonical one.
- This workflow is invoked exclusively via `workflow_dispatch`. The stage-1
workflow `Check requirements (changes detection)` runs on `pull_request` with
a paths filter on the tracked requirements files, and its completion triggers
the dispatcher (`Check requirements (dispatcher)`) which calls this workflow
with the PR number. Members can also dispatch this workflow manually with the
PR number to re-run the check after updating the PR description or fixing
issues without changing any requirements files. On a retrigger the existing
comment is updated in place so there is always exactly one requirements-check
comment in the PR.
+5 -1
View File
@@ -1088,6 +1088,7 @@ jobs:
options: >-
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/mysql:size=2g,mode=0750
needs:
- info
- base
@@ -1245,7 +1246,10 @@ jobs:
- 5432:5432
env:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
options: >-
--health-cmd="pg_isready -hlocalhost -Upostgres"
--health-interval=5s --health-timeout=2s --health-retries=3
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
needs:
- info
- base
+3 -1
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.12
rev: v0.15.13
hooks:
- id: ruff-check
args:
@@ -23,6 +23,7 @@ repos:
- id: zizmor
args:
- --pedantic
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
@@ -46,6 +47,7 @@ repos:
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.2.0
exclude: ^\.github/workflows/.*\.lock\.yml$
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
+1
View File
@@ -1,5 +1,6 @@
ignore: |
tests/fixtures/core/config/yaml_errors/
.github/workflows/*.lock.yml
rules:
braces:
level: error
Generated
+2
View File
@@ -68,6 +68,8 @@ CLAUDE.md @home-assistant/core
/tests/components/agent_dvr/ @ispysoftware
/homeassistant/components/ai_task/ @home-assistant/core
/tests/components/ai_task/ @home-assistant/core
/homeassistant/components/aidot/ @s1eedz @HongBryan
/tests/components/aidot/ @s1eedz @HongBryan
/homeassistant/components/air_quality/ @home-assistant/core
/tests/components/air_quality/ @home-assistant/core
/homeassistant/components/airgradient/ @airgradienthq @joostlek
+2 -2
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769
# Automatically generated by hassfest.
# Partly generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
ARG BUILD_FROM
@@ -26,7 +26,7 @@ WORKDIR /usr/src
COPY rootfs /
# Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
COPY --from=ghcr.io/alexxit/go2rtc:1.9.14@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc
## Setup Home Assistant Core dependencies
COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.8.0"]
}
+1
View File
@@ -19,6 +19,7 @@ from .hub import AdsHub
DEFAULT_NAME = "ADS select"
# pylint: disable-next=home-assistant-duplicate-const
CONF_OPTIONS = "options"
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
@@ -0,0 +1,25 @@
"""The aidot integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AidotConfigEntry, AidotDeviceManagerCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Set up aidot from a config entry."""
coordinator = AidotDeviceManagerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
return True
async def async_unload_entry(hass: HomeAssistant, entry: AidotConfigEntry) -> bool:
"""Unload a config entry."""
await entry.runtime_data.async_cleanup()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,66 @@
"""Config flow for Aidot integration."""
from typing import Any
from aidot.client import AidotClient
from aidot.const import CONF_ID, DEFAULT_COUNTRY_CODE, SUPPORTED_COUNTRY_CODES
from aidot.exceptions import AidotUserOrPassIncorrect
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_COUNTRY_CODE,
default=DEFAULT_COUNTRY_CODE,
): selector.CountrySelector(
selector.CountrySelectorConfig(
countries=SUPPORTED_COUNTRY_CODES,
)
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class AidotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle aidot config flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = AidotClient(
session=async_get_clientsession(self.hass),
country_code=user_input[CONF_COUNTRY_CODE],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
try:
login_info = await client.async_post_login()
except AidotUserOrPassIncorrect:
errors["base"] = "invalid_auth"
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(login_info[CONF_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_USERNAME]} {user_input[CONF_COUNTRY_CODE]}",
data=login_info,
)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
+3
View File
@@ -0,0 +1,3 @@
"""Constants for the aidot integration."""
DOMAIN = "aidot"
@@ -0,0 +1,163 @@
"""Coordinator for Aidot."""
from datetime import timedelta
import logging
from aidot.client import AidotClient
from aidot.const import (
CONF_ACCESS_TOKEN,
CONF_AES_KEY,
CONF_DEVICE_LIST,
CONF_ID,
CONF_TYPE,
)
from aidot.device_client import DeviceClient, DeviceStatusData
from aidot.exceptions import AidotAuthFailed, AidotUserOrPassIncorrect
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
type AidotConfigEntry = ConfigEntry[AidotDeviceManagerCoordinator]
_LOGGER = logging.getLogger(__name__)
UPDATE_DEVICE_LIST_INTERVAL = timedelta(hours=6)
class AidotDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceStatusData]):
"""Class to manage Aidot data."""
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
device_client: DeviceClient,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=None,
)
self.device_client = device_client
async def _async_setup(self) -> None:
"""Set up the coordinator."""
self.device_client.on_status_update = self._handle_status_update
def _handle_status_update(self, status: DeviceStatusData) -> None:
"""Handle status callback."""
self.async_set_updated_data(status)
async def _async_update_data(self) -> DeviceStatusData:
"""Return current status."""
return self.device_client.status
class AidotDeviceManagerCoordinator(DataUpdateCoordinator[None]):
"""Class to manage fetching Aidot data."""
config_entry: AidotConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AidotConfigEntry,
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_DEVICE_LIST_INTERVAL,
)
self.client = AidotClient(
session=async_get_clientsession(hass),
token=config_entry.data,
)
self.client.set_token_fresh_cb(self.token_fresh_cb)
self.device_coordinators: dict[str, AidotDeviceUpdateCoordinator] = {}
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
await self.async_auto_login()
except AidotUserOrPassIncorrect as error:
raise ConfigEntryError from error
async def _async_update_data(self) -> None:
"""Update data async."""
try:
data = await self.client.async_get_all_device()
except AidotAuthFailed as error:
raise ConfigEntryError from error
current_devices = {
device[CONF_ID]: device
for device in data[CONF_DEVICE_LIST]
if (
device[CONF_TYPE] == "light"
and CONF_AES_KEY in device
and device[CONF_AES_KEY][0] is not None
)
}
removed_ids = set(self.device_coordinators) - set(current_devices)
for dev_id in removed_ids:
coordinator = self.device_coordinators.pop(dev_id)
coordinator.device_client.on_status_update = None
if removed_ids:
self._purge_deleted_lists()
for dev_id, device in current_devices.items():
if dev_id not in self.device_coordinators:
device_client = self.client.get_device_client(device)
device_coordinator = AidotDeviceUpdateCoordinator(
self.hass, self.config_entry, device_client
)
await device_coordinator.async_config_entry_first_refresh()
self.device_coordinators[dev_id] = device_coordinator
async def async_cleanup(self) -> None:
"""Perform cleanup actions."""
for coordinator in self.device_coordinators.values():
coordinator.device_client.on_status_update = None
await self.client.async_cleanup()
def token_fresh_cb(self) -> None:
"""Update token."""
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.client.login_info.copy()
)
async def async_auto_login(self) -> None:
"""Async auto login."""
if self.client.login_info.get(CONF_ACCESS_TOKEN) is None:
await self.client.async_post_login()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(
DOMAIN,
device_coordinator.device_client.info.dev_id,
)
for device_coordinator in self.device_coordinators.values()
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)
+122
View File
@@ -0,0 +1,122 @@
"""Support for Aidot lights."""
from typing import Any
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGBW_COLOR,
ColorMode,
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AidotConfigEntry, AidotDeviceUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: AidotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Light."""
coordinator = entry.runtime_data
async_add_entities(
AidotLight(device_coordinator)
for device_coordinator in coordinator.device_coordinators.values()
)
class AidotLight(CoordinatorEntity[AidotDeviceUpdateCoordinator], LightEntity):
"""Representation of a Aidot Wi-Fi Light."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: AidotDeviceUpdateCoordinator) -> None:
"""Initialize the light."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.device_client.info.dev_id
if hasattr(coordinator.device_client.info, "cct_max"):
self._attr_max_color_temp_kelvin = coordinator.device_client.info.cct_max
if hasattr(coordinator.device_client.info, "cct_min"):
self._attr_min_color_temp_kelvin = coordinator.device_client.info.cct_min
model_id = coordinator.device_client.info.model_id
manufacturer = model_id.split(".")[0]
model = model_id[len(manufacturer) + 1 :]
mac = coordinator.device_client.info.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
connections={(CONNECTION_NETWORK_MAC, mac)},
manufacturer=manufacturer,
model=model,
name=coordinator.device_client.info.name,
hw_version=coordinator.device_client.info.hw_version,
)
if coordinator.device_client.info.enable_rgbw:
self._attr_color_mode = ColorMode.RGBW
self._attr_supported_color_modes = {ColorMode.RGBW, ColorMode.COLOR_TEMP}
elif coordinator.device_client.info.enable_cct:
self._attr_color_mode = ColorMode.COLOR_TEMP
self._attr_supported_color_modes = {ColorMode.COLOR_TEMP}
else:
self._attr_color_mode = ColorMode.BRIGHTNESS
self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
self._update_status()
def _update_status(self) -> None:
"""Update light status from coordinator data."""
self._attr_is_on = self.coordinator.data.on
self._attr_brightness = self.coordinator.data.dimming
self._attr_color_temp_kelvin = self.coordinator.data.cct
self._attr_rgbw_color = self.coordinator.data.rgbw
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.coordinator.data.online
@callback
def _handle_coordinator_update(self) -> None:
"""Update."""
self._update_status()
super()._handle_coordinator_update()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on, applying brightness, color temperature, RGBW, or plain on."""
if ATTR_BRIGHTNESS in kwargs:
brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
await self.coordinator.device_client.async_set_brightness(brightness)
self.coordinator.data.dimming = brightness
self._attr_brightness = brightness
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
await self.coordinator.device_client.async_set_cct(color_temp_kelvin)
self.coordinator.data.cct = color_temp_kelvin
self._attr_color_temp_kelvin = color_temp_kelvin
self._attr_color_mode = ColorMode.COLOR_TEMP
elif ATTR_RGBW_COLOR in kwargs:
rgbw_color = kwargs.get(ATTR_RGBW_COLOR)
await self.coordinator.device_client.async_set_rgbw(rgbw_color)
self.coordinator.data.rgbw = rgbw_color
self._attr_rgbw_color = rgbw_color
self._attr_color_mode = ColorMode.RGBW
else:
await self.coordinator.device_client.async_turn_on()
self.coordinator.data.on = True
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.coordinator.device_client.async_turn_off()
self.coordinator.data.on = False
self._attr_is_on = False
self.async_write_ha_state()
@@ -0,0 +1,11 @@
{
"domain": "aidot",
"name": "AiDot",
"codeowners": ["@s1eedz", "@HongBryan"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aidot",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["python-aidot==0.3.53"]
}
@@ -0,0 +1,67 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not register any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has no option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
entity-disabled-by-default: todo
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,25 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"user": {
"data": {
"country_code": "Country",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"country_code": "The country selected by AiDot app when logging in",
"password": "Password for logging in through AiDot app",
"username": "Account logged in through AiDot app"
}
}
}
}
}
@@ -2,4 +2,5 @@
DOMAIN = "altruist"
# pylint: disable-next=home-assistant-duplicate-const
CONF_HOST = "host"
@@ -24,10 +24,10 @@ from homeassistant.const import (
CONF_API_KEY,
CONF_LLM_HASS_API,
CONF_NAME,
CONF_PROMPT,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import config_validation as cv, llm
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
@@ -44,12 +44,13 @@ from .const import (
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_PROMPT_CACHING,
CONF_RECOMMENDED,
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -452,11 +453,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
): cv.positive_int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
vol.Optional(
CONF_WEB_FETCH,
default=DEFAULT[CONF_WEB_FETCH],
): bool,
vol.Optional(
CONF_WEB_FETCH_MAX_USES,
default=DEFAULT[CONF_WEB_FETCH_MAX_USES],
): cv.positive_int,
}
)
+4 -1
View File
@@ -10,7 +10,6 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model"
CONF_CODE_EXECUTION = "code_execution"
CONF_MAX_TOKENS = "max_tokens"
@@ -18,6 +17,8 @@ CONF_PROMPT_CACHING = "prompt_caching"
CONF_THINKING_BUDGET = "thinking_budget"
CONF_THINKING_EFFORT = "thinking_effort"
CONF_TOOL_SEARCH = "tool_search"
CONF_WEB_FETCH = "web_fetch"
CONF_WEB_FETCH_MAX_USES = "web_fetch_max_uses"
CONF_WEB_SEARCH = "web_search"
CONF_WEB_SEARCH_USER_LOCATION = "user_location"
CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses"
@@ -45,6 +46,8 @@ DEFAULT = {
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_FETCH: False,
CONF_WEB_FETCH_MAX_USES: 5,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_WEB_SEARCH_MAX_USES: 5,
@@ -4,12 +4,12 @@ from typing import Literal
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.const import CONF_LLM_HASS_API, CONF_PROMPT, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthropicConfigEntry
from .const import CONF_PROMPT, DOMAIN
from .const import DOMAIN
from .entity import AnthropicBaseLLMEntity
@@ -5,11 +5,10 @@ from typing import TYPE_CHECKING, Any
from anthropic import __title__, __version__
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_PROMPT
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
+55 -17
View File
@@ -17,8 +17,6 @@ from anthropic.types import (
Base64PDFSourceParam,
BashCodeExecutionToolResultBlock,
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
@@ -70,6 +68,9 @@ from anthropic.types import (
ToolUseBlock,
ToolUseBlockParam,
Usage,
WebFetchTool20250910Param,
WebFetchTool20260209Param,
WebFetchToolResultBlock,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
@@ -97,6 +98,12 @@ from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
from anthropic.types.tool_use_block import Caller
from anthropic.types.web_fetch_tool_result_block import (
Content as WebFetchToolResultBlockContent,
)
from anthropic.types.web_fetch_tool_result_block_param import (
Content as WebFetchToolResultBlockParamContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -118,6 +125,8 @@ from .const import (
CONF_THINKING_BUDGET,
CONF_THINKING_EFFORT,
CONF_TOOL_SEARCH,
CONF_WEB_FETCH,
CONF_WEB_FETCH_MAX_USES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
@@ -208,17 +217,9 @@ class ContentDetails:
"""Add a citation to the current detail."""
if not self.citation_details:
self.citation_details.append(CitationDetails())
citation_param: TextCitationParam | None = None
if isinstance(citation, CitationsWebSearchResultLocation):
citation_param = CitationWebSearchResultLocationParam(
type="web_search_result_location",
title=citation.title,
url=citation.url,
cited_text=citation.cited_text,
encrypted_index=citation.encrypted_index,
)
if citation_param:
self.citation_details[-1].citations.append(citation_param)
self.citation_details[-1].citations.append(
cast(TextCitationParam, citation.to_dict())
)
def delete_empty(self) -> None:
"""Delete empty citation details."""
@@ -289,6 +290,15 @@ def _convert_content( # noqa: C901
content.tool_result,
),
}
elif content.tool_name == "web_fetch":
tool_result_block = {
"type": "web_fetch_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
WebFetchToolResultBlockParamContentParam,
content.tool_result,
),
}
else:
tool_result_block = {
"type": "tool_result",
@@ -415,6 +425,7 @@ def _convert_content( # noqa: C901
id=tool_call.id,
name=cast(
Literal[
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -428,6 +439,7 @@ def _convert_content( # noqa: C901
if tool_call.external
and tool_call.tool_name
in [
"web_fetch",
"web_search",
"code_execution",
"bash_code_execution",
@@ -609,6 +621,7 @@ class AnthropicDeltaStream:
if isinstance(
content_block,
(
WebFetchToolResultBlock,
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
@@ -724,13 +737,15 @@ class AnthropicDeltaStream:
self,
tool_use_id: str,
tool_name: Literal[
"web_fetch_tool_result",
"web_search_tool_result",
"code_execution_tool_result",
"bash_code_execution_tool_result",
"text_editor_code_execution_tool_result",
"tool_search_tool_result",
],
content: WebSearchToolResultBlockContent
content: WebFetchToolResultBlockContent
| WebSearchToolResultBlockContent
| CodeExecutionToolResultBlockContent
| BashCodeExecutionToolResultBlockContent
| TextEditorCodeExecutionToolResultBlockContent
@@ -907,6 +922,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"GetLiveContext",
"code_execution",
"web_search",
"web_fetch",
]
system = chat_log.content[0]
@@ -980,12 +996,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
]
if options[CONF_CODE_EXECUTION]:
# The `web_search_20260209` tool automatically enables
# `code_execution_20260120` tool
# The `web_search_20260209` and `web_fetch_20260209` tools
# automatically enable `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_WEB_SEARCH]
or (not options[CONF_WEB_SEARCH] and not options[CONF_WEB_FETCH])
):
tools.append(
CodeExecutionTool20250825Param(
@@ -1023,6 +1039,28 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
}
tools.append(web_search)
if options[CONF_WEB_FETCH]:
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options[CONF_CODE_EXECUTION]
):
tools.append(
WebFetchTool20250910Param(
name="web_fetch",
type="web_fetch_20250910",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
else:
tools.append(
WebFetchTool20260209Param(
name="web_fetch",
type="web_fetch_20260209",
max_uses=options[CONF_WEB_FETCH_MAX_USES],
)
)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
@@ -40,9 +40,11 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._current_subentry_id = None
self._model_list_cache = None
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
async def async_step_init(
self, user_input: dict[str, str] | None
) -> RepairsFlowResult:
"""Handle the steps of a fix flow."""
if user_input.get(CONF_CHAT_MODEL):
if user_input and user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
target = await self._async_next_target()
@@ -80,6 +80,8 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
@@ -90,6 +92,8 @@
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
"tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_fetch": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch%]",
"web_fetch_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_fetch_max_uses%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
@@ -149,6 +153,8 @@
"thinking_effort": "Thinking effort",
"tool_search": "Enable tool search tool",
"user_location": "Include home location",
"web_fetch": "Enable web fetch",
"web_fetch_max_uses": "Maximum web fetches",
"web_search": "Enable web search",
"web_search_max_uses": "Maximum web searches"
},
@@ -159,6 +165,8 @@
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
"tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context",
"user_location": "Localize search results based on home location",
"web_fetch": "The web fetch tool allows Claude to retrieve full content from specified web pages and PDF documents to augment Claude's context with live web content",
"web_fetch_max_uses": "Limit the number of web fetches performed per response",
"web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff",
"web_search_max_uses": "Limit the number of searches performed per response"
},
@@ -5,7 +5,7 @@ from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -21,23 +21,33 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
manager = config_entry.runtime_data
cb: CALLBACK_TYPE
added = False
@callback
def setup_entities(atv: AppleTV) -> None:
nonlocal added
if added:
return
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
cb()
added = True
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
config_entry.async_on_unload(
async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
)
config_entry.async_on_unload(cb)
# The manager may have already connected (and dispatched SIGNAL_CONNECTED)
# before this platform was forwarded, in which case the signal above was
# missed; handle that case directly.
if manager.atv is not None:
setup_entities(manager.atv)
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
@@ -16,6 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -53,18 +53,19 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
self.state = State(client, zone)
self.update_in_progress = False
name = config_entry.title
device_name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
name += f" Zone {zone}"
device_name += f" Zone {zone}"
self.device_name = device_name
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=name,
name=device_name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
@@ -1,11 +1,36 @@
"""Base entity for Arcam FMJ integration."""
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from arcam.fmj import ConnectionFailed
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ArcamFmjCoordinator
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Convert a connection failure into a translated HomeAssistantError."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
@@ -1,11 +1,9 @@
"""Arcam media player."""
from collections.abc import Callable, Coroutine
import functools
import logging
from typing import Any
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj import SourceCodes
from homeassistant.components.media_player import (
BrowseError,
@@ -18,15 +16,19 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
from .entity import ArcamFmjEntity, convert_exception
_LOGGER = logging.getLogger(__name__)
# arcam-fmj serializes commands on a single TCP writer at the library
# layer; serialize at HA's layer to match the device's contract.
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
@@ -41,23 +43,6 @@ async def async_setup_entry(
)
def convert_exception[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Return decorator to convert a connection error into a home assistant error."""
@functools.wraps(func)
async def _convert_exception(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(*args, **kwargs)
except ConnectionFailed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="connection_failed"
) from exception
return _convert_exception
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
@@ -79,11 +64,17 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self._state.get_power():
return MediaPlayerState.ON
return MediaPlayerState.OFF
def state(self) -> MediaPlayerState | None:
"""Return the state of the device.
``None`` is returned (surfaced as ``unknown``) when the device has
not yet reported a power state; this is distinct from a real
powered-off state and must not be collapsed to ``OFF``.
"""
power = self._state.get_power()
if power is None:
return None
return MediaPlayerState.ON if power else MediaPlayerState.OFF
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
@@ -179,7 +170,7 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
]
return BrowseMedia(
title="Arcam FMJ Receiver",
title=self.coordinator.device_name,
media_class=MediaClass.DIRECTORY,
media_content_id="root",
media_content_type="library",
@@ -22,6 +22,9 @@ from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
# Read-only, coordinator-driven entities; no per-entity I/O to bound.
PARALLEL_UPDATES = 0
def _enum_options(value: type[IntOrTypeEnum]) -> list[str]:
return [
@@ -19,6 +19,8 @@ DEVICES = "devices"
MANUFACTURER = "ABB"
ATTR_DEVICE_NAME = "device_name"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_DEVICE_ID = "device_id"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
ATTR_FIRMWARE = "firmware"
@@ -3,7 +3,7 @@
from autoskope_client.constants import MANUFACTURER
from autoskope_client.models import Vehicle
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -113,11 +113,6 @@ class AutoskopeDeviceTracker(
return float(vehicle.position.longitude)
return None
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device in meters."""
+38 -8
View File
@@ -32,6 +32,7 @@ from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
AVEA_MAX_BRIGHTNESS = 4095
def _normalize_name(name: str | None) -> str | None:
@@ -41,6 +42,16 @@ def _normalize_name(name: str | None) -> str | None:
return name
def _ha_brightness_to_avea(brightness: int) -> int:
"""Convert Home Assistant brightness to Avea brightness."""
return round((brightness / 255) * AVEA_MAX_BRIGHTNESS)
def _avea_brightness_to_ha(brightness: int) -> int:
"""Convert Avea brightness to Home Assistant brightness."""
return round(255 * (brightness / AVEA_MAX_BRIGHTNESS))
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
@@ -84,7 +95,9 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Avea light platform."""
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
async_add_entities(
[AveaLight(entry.runtime_data, entry.title)], update_before_add=True
)
def _discover_bulbs_for_import() -> list[dict[str, str]]:
@@ -169,30 +182,47 @@ class AveaLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb) -> None:
def __init__(self, light: avea.Bulb, entry_title: str) -> None:
"""Initialize an AveaLight."""
self._light = light
self._attr_name = light.name
self._attr_name = entry_title
self._attr_brightness = light.brightness
self._last_brightness = 255
def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
if not kwargs:
self._light.set_brightness(4095)
self._light.set_brightness(_ha_brightness_to_avea(self._last_brightness))
else:
if ATTR_BRIGHTNESS in kwargs:
bright = round((kwargs[ATTR_BRIGHTNESS] / 255) * 4095)
self._light.set_brightness(bright)
brightness = kwargs[ATTR_BRIGHTNESS]
if brightness:
self._last_brightness = brightness
self._light.set_brightness(_ha_brightness_to_avea(brightness))
if ATTR_HS_COLOR in kwargs:
rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
self._light.set_rgb(rgb[0], rgb[1], rgb[2])
def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
if self._attr_brightness:
self._last_brightness = self._attr_brightness
self._light.set_brightness(0)
def update(self) -> None:
"""Fetch new state data for this light."""
if (brightness := self._light.get_brightness()) is not None:
connected = self._light.connect()
try:
brightness = self._light.get_brightness()
rgb_color = self._light.get_rgb()
finally:
if connected:
self._light.disconnect()
if brightness is not None:
self._attr_is_on = brightness != 0
self._attr_brightness = round(255 * (brightness / 4095))
self._attr_brightness = _avea_brightness_to_ha(brightness)
if self._attr_brightness:
self._last_brightness = self._attr_brightness
self._attr_hs_color = color_util.color_RGB_to_hs(*rgb_color)
+1
View File
@@ -11,6 +11,7 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
AWS_DOMAIN = "amazonaws.com"
@@ -20,12 +20,12 @@ from homeassistant.components.backup import (
OnProgressCallback,
suggested_filename,
)
from homeassistant.const import CONF_PREFIX
from homeassistant.core import HomeAssistant, callback
from homeassistant.util.async_iterator import AsyncIteratorReader
from . import BackblazeConfigEntry
from .const import (
CONF_PREFIX,
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
METADATA_FILE_SUFFIX,
@@ -175,12 +175,13 @@ class BackblazeBackupAgent(BackupAgent):
"Attempting to delete partially uploaded backup file %s",
filename,
)
def _delete_uploaded_file() -> None:
"""Look up and delete the partially uploaded backup file."""
self._bucket.get_file_info_by_name(filename).delete()
try:
uploaded_main_file_info = await self._hass.async_add_executor_job(
self._bucket.get_file_info_by_name, filename
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
await self._hass.async_add_executor_job(_delete_uploaded_file)
except B2Error:
_LOGGER.warning(
"Failed to clean up partially uploaded backup file %s;"
@@ -386,9 +387,12 @@ class BackblazeBackupAgent(BackupAgent):
metadata_file.file_name,
)
await self._hass.async_add_executor_job(file.delete)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self._hass.async_add_executor_job(metadata_file.delete)
def _delete_backup_files() -> None:
"""Delete the backup file and its metadata file."""
file.delete()
metadata_file.delete()
await self._hass.async_add_executor_job(_delete_backup_files)
self._invalidate_caches(
backup_id,
@@ -8,6 +8,7 @@ from b2sdk.v2 import exception
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PREFIX
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
@@ -22,7 +23,6 @@ from .const import (
CONF_APPLICATION_KEY,
CONF_BUCKET,
CONF_KEY_ID,
CONF_PREFIX,
DOMAIN,
)
@@ -10,7 +10,6 @@ DOMAIN: Final = "backblaze_b2"
CONF_KEY_ID = "key_id"
CONF_APPLICATION_KEY = "application_key"
CONF_BUCKET = "bucket"
CONF_PREFIX = "prefix"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
+8 -2
View File
@@ -80,7 +80,8 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
def _color_temp_to_native_scale(self, x: int) -> int:
"""Convert color temperature from Kelvin to native BleBox scale (0-255).
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
BleBox native scale is inverted:
0=warm (2700K), 255=cold (6500K).
"""
scaled = (
(self._attr_max_color_temp_kelvin - x)
@@ -98,7 +99,8 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
def _color_temp_from_native_scale(self, x: int) -> int:
"""Convert color temperature from native BleBox scale (0-255) to Kelvin.
BleBox native scale is inverted relative to Kelvin: 0=warm (2700K), 255=cold (6500K).
BleBox native scale is inverted:
0=warm (2700K), 255=cold (6500K).
"""
scaled = self._attr_max_color_temp_kelvin - (x / 255) * (
self._attr_max_color_temp_kelvin - self._attr_min_color_temp_kelvin
@@ -201,6 +203,10 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity):
else:
value = feature.apply_brightness(value, brightness)
if isinstance(value, (list, tuple)) and not any(value):
await self._feature.async_off()
return
try:
await self._feature.async_on(value)
except ValueError as exc:
@@ -16,6 +16,7 @@ CONF_DETAILS = "details"
CONF_PASSIVE = "passive"
# pylint: disable-next=home-assistant-duplicate-const
CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
@@ -89,7 +89,9 @@ class PassiveBluetoothDataUpdateCoordinator(
class PassiveBluetoothCoordinatorEntity[ # pylint: disable=home-assistant-enforce-class-module
_PassiveBluetoothDataUpdateCoordinatorT: PassiveBluetoothDataUpdateCoordinator = PassiveBluetoothDataUpdateCoordinator
_PassiveBluetoothDataUpdateCoordinatorT: (
PassiveBluetoothDataUpdateCoordinator
) = PassiveBluetoothDataUpdateCoordinator
](BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT]):
"""A class for entities using DataUpdateCoordinator."""
@@ -94,8 +94,8 @@ def serialize_service_info(
"address": service_info.address,
"rssi": service_info.rssi,
"manufacturer_data": {
str(manufacturer_id): manufacturer_data.hex()
for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items()
str(manufacturer_id): data.hex()
for manufacturer_id, data in service_info.manufacturer_data.items()
},
"service_data": {
service_uuid: service_data.hex()
@@ -6,6 +6,7 @@ from typing import Final
ATTR_CID: Final = "cid"
ATTR_MAC: Final = "macAddr"
ATTR_MANUFACTURER: Final = "Sony"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
+1 -1
View File
@@ -8,6 +8,7 @@ from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -20,7 +21,6 @@ if TYPE_CHECKING:
LOGGER = logging.getLogger(__name__)
ATTR_DEVICE_ID = "device_id"
ATTR_MONDAY_SLOTS = "monday_slots"
ATTR_TUESDAY_SLOTS = "tuesday_slots"
ATTR_WEDNESDAY_SLOTS = "wednesday_slots"
@@ -151,7 +151,9 @@ def sensor_update_to_bluetooth_data_update(
device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[
description.device_class
]
for device_key, description in sensor_update.binary_entity_descriptions.items()
for device_key, description in (
sensor_update.binary_entity_descriptions.items()
)
if description.device_class
},
entity_data={
+4 -2
View File
@@ -52,14 +52,16 @@ async def async_get_calendars(
warned_calendars.add((url, comp))
if comp in ASSUMED_COMPONENTS:
_LOGGER.warning(
"CalDAV server does not report supported components for calendar %s, "
"CalDAV server does not report supported"
" components for calendar %s, "
"assuming it supports the requested component '%s'",
name or url,
comp,
)
else:
_LOGGER.warning(
"CalDAV server does not report supported components for calendar %s. "
"CalDAV server does not report supported"
" components for calendar %s. "
"Not assuming support for requested component '%s'",
name or url,
comp,
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
DOMAIN = "calendar"
DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN)
# pylint: disable-next=home-assistant-duplicate-const
CONF_EVENT = "event"
@@ -31,7 +31,9 @@ async def async_setup_entry(
) -> bool:
"""Set up Cambridge Audio integration from a config entry."""
client = StreamMagicClient(entry.data[CONF_HOST], async_get_clientsession(hass))
client = StreamMagicClient(
entry.data[CONF_HOST], async_get_clientsession(hass), should_close_session=False
)
async def _connection_update_callback(
_client: StreamMagicClient, _callback_type: CallbackType
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
"requirements": ["aiostreammagic==2.13.0"],
"requirements": ["aiostreammagic==2.13.1"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
+40 -73
View File
@@ -8,7 +8,7 @@ from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.data_entry_flow import SectionConfig, section
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -17,7 +17,7 @@ from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
if TYPE_CHECKING:
from . import CastConfigEntry
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
CONF_MORE_OPTIONS = "more_options"
KNOWN_HOSTS_SCHEMA = vol.Schema(
{
vol.Optional(
@@ -27,7 +27,19 @@ KNOWN_HOSTS_SCHEMA = vol.Schema(
)
}
)
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
OPTIONS_SCHEMA = KNOWN_HOSTS_SCHEMA.extend(
{
vol.Required(CONF_MORE_OPTIONS): section(
vol.Schema(
{
vol.Optional(CONF_UUID): str,
vol.Optional(CONF_IGNORE_CEC): str,
}
),
SectionConfig(collapsed=True),
)
}
)
class FlowHandler(ConfigFlow, domain=DOMAIN):
@@ -92,100 +104,55 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
class CastOptionsFlowHandler(OptionsFlow):
"""Handle Google Cast options."""
def __init__(self) -> None:
"""Initialize Google Cast options flow."""
self.updated_config: dict[str, Any] = {}
async def async_step_init(self, user_input: None = None) -> ConfigFlowResult:
"""Manage the Google Cast options."""
return await self.async_step_basic_options()
async def async_step_basic_options(
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
if user_input is not None:
ignore_cec = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_IGNORE_CEC, "")
)
known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
self.updated_config = dict(self.config_entry.data)
self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
if self.show_advanced_options:
return await self.async_step_advanced_options()
wanted_uuid = _string_to_list(
user_input[CONF_MORE_OPTIONS].get(CONF_UUID, "")
)
updated_config = dict(self.config_entry.data)
updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts
updated_config[CONF_UUID] = wanted_uuid
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.updated_config
self.config_entry, data=updated_config
)
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="basic_options",
data_schema=self.add_suggested_values_to_schema(
KNOWN_HOSTS_SCHEMA, self.config_entry.data
),
errors=errors,
last_step=not self.show_advanced_options,
)
async def async_step_advanced_options(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
if user_input is not None:
bad_cec, ignore_cec = _string_to_list(
user_input.get(CONF_IGNORE_CEC, ""), IGNORE_CEC_SCHEMA
)
bad_uuid, wanted_uuid = _string_to_list(
user_input.get(CONF_UUID, ""), WANTED_UUID_SCHEMA
suggested: dict[str, Any] = {CONF_MORE_OPTIONS: {}}
if CONF_KNOWN_HOSTS in self.config_entry.data:
suggested[CONF_KNOWN_HOSTS] = self.config_entry.data[CONF_KNOWN_HOSTS]
for key in (CONF_UUID, CONF_IGNORE_CEC):
if key not in self.config_entry.data:
continue
suggested[CONF_MORE_OPTIONS][key] = _list_to_string(
self.config_entry.data[key]
)
if not bad_cec and not bad_uuid:
self.updated_config[CONF_IGNORE_CEC] = ignore_cec
self.updated_config[CONF_UUID] = wanted_uuid
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.updated_config
)
return self.async_create_entry(title="", data={})
fields: dict[vol.Marker, type[str]] = {}
current_config = self.config_entry.data
suggested_value = _list_to_string(current_config.get(CONF_UUID))
_add_with_suggestion(fields, CONF_UUID, suggested_value)
suggested_value = _list_to_string(current_config.get(CONF_IGNORE_CEC))
_add_with_suggestion(fields, CONF_IGNORE_CEC, suggested_value)
return self.async_show_form(
step_id="advanced_options",
data_schema=vol.Schema(fields),
errors=errors,
step_id="init",
data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, suggested),
last_step=True,
)
def _list_to_string(items):
def _list_to_string(items: list[str]) -> str:
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string
def _string_to_list(string, schema):
invalid = False
items = [x.strip() for x in string.split(",") if x.strip()]
try:
items = schema(items)
except vol.Invalid:
invalid = True
return invalid, items
def _string_to_list(string: str) -> list[str]:
return [x.strip() for x in string.split(",") if x.strip()]
def _trim_items(items: list[str]) -> list[str]:
return [x.strip() for x in items if x.strip()]
def _add_with_suggestion(
fields: dict[vol.Marker, type[str]], key: str, suggested_value: str
) -> None:
fields[vol.Optional(key, description={"suggested_value": suggested_value})] = str
@@ -718,9 +718,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
await self.hass.async_add_executor_job(
self._quick_play, app_name, app_data
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except NotImplementedError:
_LOGGER.error("App %s not supported", app_name)
except NotImplementedError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="app_not_supported",
translation_placeholders={"app_name": app_name},
) from err
return
# Try the cast platforms
@@ -769,6 +772,8 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
media_id,
err,
)
# Fallback: if playlist parsing fails, forward the raw URL to the device
# pylint: disable-next=home-assistant-action-swallowed-exception
except PlaylistError as err:
_LOGGER.warning(
"[%s %s] Failed to parse playlist %s: %s",
+19 -12
View File
@@ -18,26 +18,33 @@
}
}
},
"exceptions": {
"app_not_supported": {
"message": "App {app_name} is not supported"
}
},
"options": {
"error": {
"invalid_known_hosts": "[%key:component::cast::config::error::invalid_known_hosts%]"
},
"step": {
"advanced_options": {
"data": {
"ignore_cec": "Ignore CEC",
"uuid": "Allowed UUIDs"
},
"description": "Allowed UUIDs - A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you dont want to add all available cast devices.\nIgnore CEC - A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.",
"title": "Advanced Google Cast configuration"
},
"basic_options": {
"init": {
"data": {
"known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]"
},
"data_description": {
"known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]"
},
"sections": {
"more_options": {
"data": {
"ignore_cec": "Ignore CEC",
"uuid": "Allowed UUIDs"
},
"data_description": {
"ignore_cec": "A comma-separated list of Chromecasts that should ignore CEC data for determining the active input. This will be passed to pychromecast.IGNORE_CEC.",
"uuid": "A comma-separated list of UUIDs of Cast devices to add to Home Assistant. Use only if you dont want to add all available cast devices."
},
"name": "More options"
}
},
"title": "[%key:component::cast::config::step::config::title%]"
}
}
@@ -45,7 +45,9 @@ HA_USER_AGENT = (
)
ATTR_UID = "uid"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LATITUDE = "latitude"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LONGITUDE = "longitude"
ATTR_EMPTY_SLOTS = "empty_slots"
ATTR_FREE_EBIKES = "free_ebikes"
@@ -29,6 +29,7 @@ BASE_API_URL = "https://rest.clicksend.com/v3"
HEADERS = {"Content-Type": CONTENT_TYPE_JSON}
# pylint: disable-next=home-assistant-duplicate-const
CONF_LANGUAGE = "language"
CONF_VOICE = "voice"
+5 -2
View File
@@ -220,8 +220,11 @@ class CloudClient(Interface):
)
if is_cloud_ice_servers_enabled:
if self._cloud_ice_servers_listener is None:
self._cloud_ice_servers_listener = await self.cloud.ice_servers.async_register_ice_servers_listener(
register_cloud_ice_server
ice_servers = self.cloud.ice_servers
self._cloud_ice_servers_listener = (
await ice_servers.async_register_ice_servers_listener(
register_cloud_ice_server
)
)
elif self._cloud_ice_servers_listener:
self._cloud_ice_servers_listener()
@@ -11,6 +11,7 @@ CONF_ACCESS_KEY_ID = "access_key_id"
CONF_SECRET_ACCESS_KEY = "secret_access_key"
CONF_ENDPOINT_URL = "endpoint_url"
CONF_BUCKET = "bucket"
# pylint: disable-next=home-assistant-duplicate-const
CONF_PREFIX = "prefix"
# R2 is S3-compatible. Endpoint should be like:
@@ -6,4 +6,5 @@ ATTR_URL = "color_extract_url"
DOMAIN = "color_extractor"
DEFAULT_NAME = "Color extractor"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_TURN_ON = "turn_on"
+6 -7
View File
@@ -94,13 +94,12 @@ class ComfoConnectFan(FanEntity):
self._handle_mode_update,
)
)
await self.hass.async_add_executor_job(
self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE
)
# pylint: disable-next=home-assistant-sequential-executor-jobs
await self.hass.async_add_executor_job(
self._ccb.comfoconnect.register_sensor, SENSOR_OPERATING_MODE_BIS
)
def _register_sensors() -> None:
self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE)
self._ccb.comfoconnect.register_sensor(SENSOR_OPERATING_MODE_BIS)
await self.hass.async_add_executor_job(_register_sensors)
def _handle_speed_update(self, value: float) -> None:
"""Handle update callbacks."""
@@ -10,10 +10,11 @@ from homeassistant.components.notify import (
)
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .const import CONF_COMMAND_TIMEOUT, DOMAIN, LOGGER
from .utils import create_platform_yaml_not_supported_issue, render_template_args
_LOGGER = logging.getLogger(__name__)
@@ -66,9 +67,18 @@ class CommandLineNotificationService(BaseNotificationService):
proc.returncode,
command,
)
# pylint: disable-next=home-assistant-action-swallowed-exception
except subprocess.TimeoutExpired:
_LOGGER.error("Timeout for command: %s", command)
except subprocess.TimeoutExpired as err:
_LOGGER.debug("Timeout for command: %s", command)
kill_subprocess(proc)
except subprocess.SubprocessError:
_LOGGER.error("Error trying to exec command: %s", command)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_error",
translation_placeholders={"command": command},
) from err
except subprocess.SubprocessError as err:
_LOGGER.debug("Error trying to exec command: %s", command)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={"command": command, "error": str(err)},
) from err
@@ -1,4 +1,12 @@
{
"exceptions": {
"command_error": {
"message": "Error trying to execute command: {command}. Error: {error}"
},
"timeout_error": {
"message": "Timeout trying to execute command: {command}"
}
},
"issues": {
"platform_yaml_not_supported": {
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
@@ -16,6 +16,7 @@ PLATFORMS = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.WATER_HEATER,
]
@@ -279,6 +279,20 @@
"no_alarm": "mdi:check-circle"
}
}
},
"switch": {
"device_on_off": {
"default": "mdi:power",
"state": {
"off": "mdi:power-off"
}
},
"force_dhw": {
"default": "mdi:water-boiler",
"state": {
"off": "mdi:water-boiler-off"
}
}
}
}
}
@@ -421,6 +421,14 @@
"weather_curve": {
"name": "Weather curve"
}
},
"switch": {
"device_on_off": {
"name": "Device on/off"
},
"force_dhw": {
"name": "Force domestic hot water"
}
}
}
}
+132
View File
@@ -0,0 +1,132 @@
"""Switch platform for Compit integration."""
from dataclasses import dataclass
from typing import Any
from compit_inext_api.consts import CompitParameter
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER_NAME
from .coordinator import CompitConfigEntry, CompitDataUpdateCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class CompitDeviceDescription:
"""Class to describe a Compit device."""
name: str
"""Name of the device."""
parameters: list[SwitchEntityDescription]
"""Parameters of the device."""
DESCRIPTIONS: dict[CompitParameter, SwitchEntityDescription] = {
CompitParameter.DEVICE_ON_OFF: SwitchEntityDescription(
key=CompitParameter.DEVICE_ON_OFF.value,
translation_key="device_on_off",
),
CompitParameter.FORCE_DHW: SwitchEntityDescription(
key=CompitParameter.FORCE_DHW.value,
translation_key="force_dhw",
),
}
DEVICE_DEFINITIONS: dict[int, CompitDeviceDescription] = {
210: CompitDeviceDescription(
name="EL750",
parameters=[DESCRIPTIONS[CompitParameter.DEVICE_ON_OFF]],
),
224: CompitDeviceDescription(
name="R 900",
parameters=[
DESCRIPTIONS[CompitParameter.FORCE_DHW],
],
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: CompitConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Compit switch entities from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
CompitSwitch(
coordinator,
device_id,
device_definition.name,
entity_description,
)
for device_id, device in coordinator.connector.all_devices.items()
if (device_definition := DEVICE_DEFINITIONS.get(device.definition.code))
for entity_description in device_definition.parameters
)
class CompitSwitch(CoordinatorEntity[CompitDataUpdateCoordinator], SwitchEntity):
"""Representation of a Compit switch entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CompitDataUpdateCoordinator,
device_id: int,
device_name: str,
entity_description: SwitchEntityDescription,
) -> None:
"""Initialize the switch entity."""
super().__init__(coordinator)
self.device_id = device_id
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(device_id))},
name=device_name,
manufacturer=MANUFACTURER_NAME,
model=device_name,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.connector.get_device(self.device_id) is not None
)
@property
def is_on(self) -> bool | None:
"""Return the state of the switch."""
value = self.coordinator.connector.get_current_option(
self.device_id, CompitParameter(self.entity_description.key)
)
return True if value == STATE_ON else False if value == STATE_OFF else None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter(self.entity_description.key), STATE_ON
)
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.connector.select_device_option(
self.device_id, CompitParameter(self.entity_description.key), STATE_OFF
)
self.async_write_ha_state()
+4 -1
View File
@@ -272,7 +272,10 @@ class Control4Climate(Control4Entity, ClimateEntity):
if data is None:
return None
humidity = data.get(CONTROL4_HUMIDITY)
return int(humidity) if humidity is not None else None
try:
return int(humidity) if humidity is not None else None
except ValueError, TypeError:
return None
@property
def hvac_mode(self) -> HVACMode:
@@ -19,6 +19,7 @@ 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)
@@ -10,6 +10,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -58,7 +59,7 @@ class CookidooCalendarEntity(CookidooBaseEntity, CalendarEntity):
if not self.coordinator.data.week_plan:
return None
today = date.today() # noqa: DTZ011
today = dt_util.now().date()
for day_data in self.coordinator.data.week_plan:
day_date = date.fromisoformat(day_data.id)
if day_date >= today and day_data.recipes:
@@ -1,7 +1,7 @@
"""DataUpdateCoordinator for the Cookidoo integration."""
from dataclasses import dataclass
from datetime import date, timedelta
from datetime import timedelta
import logging
from cookidoo_api import (
@@ -21,6 +21,7 @@ from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -81,7 +82,9 @@ class CookidooDataUpdateCoordinator(DataUpdateCoordinator[CookidooData]):
ingredient_items = await self.cookidoo.get_ingredient_items()
additional_items = await self.cookidoo.get_additional_items()
subscription = await self.cookidoo.get_active_subscription()
week_plan = await self.cookidoo.get_recipes_in_calendar_week(date.today()) # noqa: DTZ011
week_plan = await self.cookidoo.get_recipes_in_calendar_week(
dt_util.now().date()
)
except CookidooAuthException:
try:
await self.cookidoo.refresh_token()
+10 -2
View File
@@ -4,10 +4,11 @@ from datetime import timedelta
import logging
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, TIMEOUT_SEC
@@ -33,4 +34,11 @@ class DaikinCoordinator(DataUpdateCoordinator[None]):
self.device = device
async def _async_update_data(self) -> None:
await self.device.update_status()
try:
await self.device.update_status()
except DaikinException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="error_communicating",
translation_placeholders={"error": str(err)},
) from err
@@ -59,6 +59,9 @@
}
},
"exceptions": {
"error_communicating": {
"message": "Error communicating with Daikin device: {error}"
},
"zone_hvac_mode_unsupported": {
"message": "Zone temperature can only be changed when the main climate mode is heat or cool."
},
@@ -1,12 +1,20 @@
"""The Data Grand Lyon integration."""
import asyncio
from data_grand_lyon_ha import DataGrandLyonClient
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
from .coordinator import (
DataGrandLyonConfigEntry,
DataGrandLyonData,
DataGrandLyonTclCoordinator,
DataGrandLyonVelovCoordinator,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -22,10 +30,16 @@ async def async_setup_entry(
password=entry.data[CONF_PASSWORD],
)
coordinator = DataGrandLyonCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
tcl_coordinator = DataGrandLyonTclCoordinator(hass, entry, client)
velov_coordinator = DataGrandLyonVelovCoordinator(hass, entry, client)
entry.runtime_data = coordinator
coordinators: list[DataUpdateCoordinator] = [tcl_coordinator, velov_coordinator]
await asyncio.gather(*(c.async_config_entry_first_refresh() for c in coordinators))
entry.runtime_data = DataGrandLyonData(
tcl_coordinator=tcl_coordinator,
velov_coordinator=velov_coordinator,
)
entry.async_on_unload(entry.add_update_listener(async_update_entry))
@@ -31,12 +31,12 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon binary sensor entities."""
coordinator = entry.runtime_data
velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovBinarySensor(coordinator, subentry, description)
DataGrandLyonVelovBinarySensor(velov_coordinator, subentry, description)
for description in VELOV_BINARY_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -50,6 +50,5 @@ class DataGrandLyonVelovBinarySensor(DataGrandLyonVelovEntity, BinarySensorEntit
def is_on(self) -> bool:
"""Return true if the station is open."""
return (
self.coordinator.data.velov_stations[self._subentry_id].status
== VelovStationStatus.OPEN
self.coordinator.data[self._subentry_id].status == VelovStationStatus.OPEN
)
@@ -28,19 +28,20 @@ from .const import (
SUBENTRY_TYPE_VELOV_STATION,
)
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
@dataclass
class DataGrandLyonCoordinatorData:
"""Data returned by the coordinator."""
class DataGrandLyonData:
"""Runtime data for the Data Grand Lyon integration."""
stops: dict[str, list[TclPassage]]
velov_stations: dict[str, VelovStation]
tcl_coordinator: DataGrandLyonTclCoordinator
velov_coordinator: DataGrandLyonVelovCoordinator
class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorData]):
"""Coordinator for the Data Grand Lyon integration."""
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonData]
class DataGrandLyonTclCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
"""Coordinator for TCL transit passages."""
config_entry: DataGrandLyonConfigEntry
@@ -56,82 +57,112 @@ class DataGrandLyonCoordinator(DataUpdateCoordinator[DataGrandLyonCoordinatorDat
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
name=f"{DOMAIN}_tcl",
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> DataGrandLyonCoordinatorData:
"""Fetch data for all monitored stops and Vélo'v stations."""
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
"""Fetch data for all monitored stops."""
stop_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
)
if not stop_subentries:
return {}
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_tcl",
) from err
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
stops: dict[str, list[TclPassage]] = {}
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
sorted_passages = sort_tcl_passages_by_time(grouped[key])
if sorted_passages:
stops[subentry.subentry_id] = sorted_passages
else:
LOGGER.warning(
"No TCL passages found for subentry %s",
subentry.subentry_id,
)
return stops
class DataGrandLyonVelovCoordinator(DataUpdateCoordinator[dict[str, VelovStation]]):
"""Coordinator for Vélo'v stations."""
config_entry: DataGrandLyonConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: DataGrandLyonConfigEntry,
client: DataGrandLyonClient,
) -> None:
"""Initialize the coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_velov",
update_interval=timedelta(minutes=5),
)
async def _async_update_data(self) -> dict[str, VelovStation]:
"""Fetch data for all monitored Vélo'v stations."""
velov_subentries = list(
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION)
)
if not velov_subentries:
return {}
has_stops = bool(stop_subentries)
has_velov = bool(velov_subentries)
stops: dict[str, list[TclPassage]] = {}
velov_stations: dict[str, VelovStation] = {}
tcl_success = not has_stops
velov_success = not has_velov
if has_stops:
try:
all_passages = await self.client.get_tcl_passages()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching TCL passages: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching TCL passages: %s", err)
else:
tcl_success = True
lines_stops = [
(subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
for subentry in stop_subentries
]
grouped = filter_tcl_passages_by_lines_stops(all_passages, lines_stops)
for subentry in stop_subentries:
key = (subentry.data[CONF_LINE], subentry.data[CONF_STOP_ID])
stops[subentry.subentry_id] = sort_tcl_passages_by_time(
grouped[key]
)
if has_velov:
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
except (ClientError, TimeoutError) as err:
LOGGER.warning("Error fetching Vélo'v stations: %s", err)
else:
velov_success = True
station_ids = [
subentry.data[CONF_STATION_ID] for subentry in velov_subentries
]
found = find_velov_stations_by_ids(all_stations, station_ids)
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
if not tcl_success and not velov_success:
try:
all_stations = await self.client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_all",
)
return DataGrandLyonCoordinatorData(stops=stops, velov_stations=velov_stations)
translation_key="update_failed_velov",
) from err
except (ClientError, TimeoutError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed_velov",
) from err
station_ids = [subentry.data[CONF_STATION_ID] for subentry in velov_subentries]
found = find_velov_stations_by_ids(all_stations, station_ids)
velov_stations: dict[str, VelovStation] = {}
for subentry in velov_subentries:
station = found[subentry.data[CONF_STATION_ID]]
if station is not None:
velov_stations[subentry.subentry_id] = station
else:
LOGGER.warning(
"Vélo'v station not found for subentry %s",
subentry.subentry_id,
)
return velov_stations
@@ -16,18 +16,16 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
"stops": {
subentry_id: [asdict(passage) for passage in passages]
for subentry_id, passages in coordinator.data.stops.items()
for subentry_id, passages in entry.runtime_data.tcl_coordinator.data.items()
},
"velov_stations": {
subentry_id: asdict(station)
for subentry_id, station in coordinator.data.velov_stations.items()
for subentry_id, station in entry.runtime_data.velov_coordinator.data.items()
},
},
}
@@ -3,20 +3,25 @@
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import DOMAIN
from .coordinator import DataGrandLyonCoordinator
from .coordinator import DataGrandLyonTclCoordinator, DataGrandLyonVelovCoordinator
class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
class DataGrandLyonEntity[_CoordinatorT: DataUpdateCoordinator](
CoordinatorEntity[_CoordinatorT]
):
"""Base entity for Data Grand Lyon."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
coordinator: _CoordinatorT,
subentry: ConfigSubentry,
description: EntityDescription,
manufacturer: str,
@@ -37,23 +42,33 @@ class DataGrandLyonEntity(CoordinatorEntity[DataGrandLyonCoordinator]):
entry_type=DeviceEntryType.SERVICE,
)
@property
def available(self) -> bool:
"""Return True if subentry data is available."""
return super().available and self._subentry_id in self.coordinator.data
class DataGrandLyonVelovEntity(DataGrandLyonEntity):
class DataGrandLyonTclEntity(DataGrandLyonEntity[DataGrandLyonTclCoordinator]):
"""Base entity for Data Grand Lyon TCL stops."""
def __init__(
self,
coordinator: DataGrandLyonTclCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the TCL entity."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
class DataGrandLyonVelovEntity(DataGrandLyonEntity[DataGrandLyonVelovCoordinator]):
"""Base entity for Data Grand Lyon Vélo'v stations."""
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
coordinator: DataGrandLyonVelovCoordinator,
subentry: ConfigSubentry,
description: EntityDescription,
) -> None:
"""Initialize the Vélo'v entity."""
super().__init__(coordinator, subentry, description, "JCDecaux", "Station")
@property
def available(self) -> bool:
"""Return True if the station data is available."""
return (
super().available
and self._subentry_id in self.coordinator.data.velov_stations
)
@@ -12,14 +12,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import SUBENTRY_TYPE_STOP, SUBENTRY_TYPE_VELOV_STATION
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
from .entity import DataGrandLyonEntity, DataGrandLyonVelovEntity
from .coordinator import DataGrandLyonConfigEntry
from .entity import DataGrandLyonTclEntity, DataGrandLyonVelovEntity
PARALLEL_UPDATES = 0
@@ -170,12 +169,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Data Grand Lyon sensor entities."""
coordinator = entry.runtime_data
tcl_coordinator = entry.runtime_data.tcl_coordinator
velov_coordinator = entry.runtime_data.velov_coordinator
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
async_add_entities(
(
DataGrandLyonStopSensor(coordinator, subentry, description)
DataGrandLyonStopSensor(tcl_coordinator, subentry, description)
for description in STOP_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
@@ -184,41 +184,31 @@ async def async_setup_entry(
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_VELOV_STATION):
async_add_entities(
(
DataGrandLyonVelovSensor(coordinator, subentry, description)
DataGrandLyonVelovSensor(velov_coordinator, subentry, description)
for description in VELOV_SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry.subentry_id,
)
class DataGrandLyonStopSensor(DataGrandLyonEntity, SensorEntity):
class DataGrandLyonStopSensor(DataGrandLyonTclEntity, SensorEntity):
"""Sensor for Data Grand Lyon stop departures."""
entity_description: DataGrandLyonStopSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonStopSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description, "TCL", "Stop")
def _get_departure(self) -> TclPassage | None:
"""Return the departure for this sensor's index, or None."""
departures = self.coordinator.data.stops.get(self._subentry_id, [])
index = self.entity_description.departure_index
if index >= len(departures):
return None
return departures[index]
@property
def available(self) -> bool:
"""Return True if the departure index exists."""
return super().available and self.entity_description.departure_index < len(
self.coordinator.data[self._subentry_id]
)
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
departure = self._get_departure()
if departure is None:
return None
departure = self.coordinator.data[self._subentry_id][
self.entity_description.departure_index
]
return self.entity_description.value_fn(departure)
@@ -227,18 +217,9 @@ class DataGrandLyonVelovSensor(DataGrandLyonVelovEntity, SensorEntity):
entity_description: DataGrandLyonVelovSensorEntityDescription
def __init__(
self,
coordinator: DataGrandLyonCoordinator,
subentry: ConfigSubentry,
description: DataGrandLyonVelovSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, subentry, description)
@property
def native_value(self) -> StateType | datetime:
"""Return the sensor value."""
return self.entity_description.value_fn(
self.coordinator.data.velov_stations[self._subentry_id]
self.coordinator.data[self._subentry_id]
)
@@ -158,11 +158,11 @@
"auth_failed": {
"message": "Authentication failed for Data Grand Lyon."
},
"update_failed_all": {
"message": "[%key:component::data_grand_lyon::exceptions::update_failed_all_stops::message%]"
"update_failed_tcl": {
"message": "Error fetching TCL departures from Data Grand Lyon."
},
"update_failed_all_stops": {
"message": "Error fetching Data Grand Lyon data: all requests failed."
"update_failed_velov": {
"message": "Error fetching Vélo'v stations from Data Grand Lyon."
}
}
}
+1
View File
@@ -43,6 +43,7 @@ PLATFORMS = [
]
ATTR_DARK = "dark"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_LOCKED = "locked"
ATTR_OFFSET = "offset"
ATTR_ON = "on"
+35 -21
View File
@@ -12,9 +12,9 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_LEVEL,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
@@ -37,49 +37,71 @@ async def async_setup_entry(
async_add_entities(
[
DemoSensor(
"sensor_1",
"sensor_1",
"Outside Temperature",
15.6,
SensorDeviceClass.TEMPERATURE,
SensorStateClass.MEASUREMENT,
UnitOfTemperature.CELSIUS,
12,
),
DemoSensor(
"battery_1",
"sensor_1",
"Outside Temperature",
12,
SensorDeviceClass.BATTERY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery",
),
DemoSensor(
"sensor_2",
"sensor_2",
"Outside Humidity",
54,
SensorDeviceClass.HUMIDITY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
None,
),
DemoSensor(
"sensor_3",
"sensor_3",
"Carbon monoxide",
54,
SensorDeviceClass.CO,
SensorStateClass.MEASUREMENT,
CONCENTRATION_PARTS_PER_MILLION,
None,
),
DemoSensor(
"sensor_4",
"sensor_4",
"Carbon dioxide",
54,
SensorDeviceClass.CO2,
SensorStateClass.MEASUREMENT,
CONCENTRATION_PARTS_PER_MILLION,
14,
),
DemoSensor(
"battery_4",
"sensor_4",
"Carbon dioxide",
99,
SensorDeviceClass.BATTERY,
SensorStateClass.MEASUREMENT,
PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_name="Battery",
),
DemoSensor(
"sensor_5",
"sensor_5",
"Power consumption",
100,
SensorDeviceClass.POWER,
SensorStateClass.MEASUREMENT,
UnitOfPower.WATT,
None,
),
DemoSumSensor(
"sensor_6",
@@ -88,7 +110,6 @@ async def async_setup_entry(
SensorDeviceClass.ENERGY,
SensorStateClass.TOTAL,
UnitOfEnergy.KILO_WATT_HOUR,
None,
"total_energy_kwh",
),
DemoSumSensor(
@@ -98,7 +119,6 @@ async def async_setup_entry(
SensorDeviceClass.ENERGY,
SensorStateClass.TOTAL,
UnitOfEnergy.MEGA_WATT_HOUR,
None,
"total_energy_mwh",
),
DemoSumSensor(
@@ -108,7 +128,6 @@ async def async_setup_entry(
SensorDeviceClass.GAS,
SensorStateClass.TOTAL,
UnitOfVolume.CUBIC_METERS,
None,
"total_gas_m3",
),
DemoSumSensor(
@@ -118,17 +137,16 @@ async def async_setup_entry(
SensorDeviceClass.GAS,
SensorStateClass.TOTAL,
UnitOfVolume.CUBIC_FEET,
None,
"total_gas_ft3",
),
DemoSensor(
unique_id="sensor_10",
device_id="sensor_10",
device_name="Thermostat",
state="eco",
device_class=SensorDeviceClass.ENUM,
state_class=None,
unit_of_measurement=None,
battery=None,
options=["away", "comfort", "eco", "sleep"],
translation_key="thermostat_mode",
),
@@ -140,20 +158,21 @@ class DemoSensor(SensorEntity):
"""Representation of a Demo sensor."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_id: str,
device_name: str | None,
state: float | str | None,
device_class: SensorDeviceClass,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
battery: int | None,
options: list[str] | None = None,
translation_key: str | None = None,
entity_category: EntityCategory | None = None,
entity_name: str | None = None,
) -> None:
"""Initialize the sensor."""
self._attr_device_class = device_class
@@ -163,15 +182,14 @@ class DemoSensor(SensorEntity):
self._attr_unique_id = unique_id
self._attr_options = options
self._attr_translation_key = translation_key
self._attr_entity_category = entity_category
self._attr_name = entity_name
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
identifiers={(DOMAIN, device_id)},
name=device_name,
)
if battery:
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
class DemoSumSensor(RestoreSensor):
"""Representation of a Demo sensor."""
@@ -187,7 +205,6 @@ class DemoSumSensor(RestoreSensor):
device_class: SensorDeviceClass,
state_class: SensorStateClass | None,
unit_of_measurement: str | None,
battery: int | None,
suggested_entity_id: str,
) -> None:
"""Initialize the sensor."""
@@ -204,9 +221,6 @@ class DemoSumSensor(RestoreSensor):
name=device_name,
)
if battery:
self._attr_extra_state_attributes = {ATTR_BATTERY_LEVEL: battery}
@callback
def _async_bump_sum(self, now: datetime) -> None:
"""Bump the sum."""
+99 -6
View File
@@ -1,26 +1,119 @@
"""The DNS IP integration."""
import asyncio
from dataclasses import dataclass
import logging
import aiodns
from aiodns.error import DNSError
from pycares import AresError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import _LOGGER, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_PORT_IPV6, DEFAULT_PORT, PLATFORMS
from .const import (
CONF_HOSTNAME,
CONF_IPV4,
CONF_IPV6,
CONF_PORT_IPV6,
CONF_RESOLVER,
CONF_RESOLVER_IPV6,
DEFAULT_PORT,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class DnsIPRuntimeData:
"""Runtime data for DNS IP integration."""
resolver_ipv4: aiodns.DNSResolver | None
resolver_ipv6: aiodns.DNSResolver | None
type DnsIPConfigEntry = ConfigEntry[DnsIPRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
"""Set up DNS IP from a config entry."""
hostname = entry.data[CONF_HOSTNAME]
resolver_ipv4: aiodns.DNSResolver | None = None
resolver_ipv6: aiodns.DNSResolver | None = None
queries: list = []
if entry.data[CONF_IPV4]:
resolver_ipv4 = aiodns.DNSResolver(
nameservers=[entry.options[CONF_RESOLVER]],
tcp_port=entry.options[CONF_PORT],
udp_port=entry.options[CONF_PORT],
)
queries.append(resolver_ipv4.query(hostname, "A"))
if entry.data[CONF_IPV6]:
resolver_ipv6 = aiodns.DNSResolver(
nameservers=[entry.options[CONF_RESOLVER_IPV6]],
tcp_port=entry.options[CONF_PORT_IPV6],
udp_port=entry.options[CONF_PORT_IPV6],
)
queries.append(resolver_ipv6.query(hostname, "AAAA"))
async def _close_resolvers() -> None:
if resolver_ipv4 is not None:
await resolver_ipv4.close()
if resolver_ipv6 is not None:
await resolver_ipv6.close()
try:
async with asyncio.timeout(10):
results = await asyncio.gather(*queries, return_exceptions=True)
except TimeoutError as err:
await _close_resolvers()
raise ConfigEntryNotReady(
f"DNS lookup timed out for {hostname}: {err}"
) from err
errors = [
result
for result in results
if isinstance(
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
)
]
if errors and len(errors) == len(results):
await _close_resolvers()
raise ConfigEntryNotReady(
f"DNS lookup failed for {hostname}: {errors[0]}"
) from errors[0]
entry.runtime_data = DnsIPRuntimeData(
resolver_ipv4=resolver_ipv4,
resolver_ipv6=resolver_ipv6,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> bool:
"""Unload DNS IP config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
if entry.runtime_data.resolver_ipv4 is not None:
await entry.runtime_data.resolver_ipv4.close()
if entry.runtime_data.resolver_ipv6 is not None:
await entry.runtime_data.resolver_ipv6.close()
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_migrate_entry(
hass: HomeAssistant, config_entry: DnsIPConfigEntry
) -> bool:
"""Migrate old entry to a newer version."""
if config_entry.version > 1:
+48 -19
View File
@@ -4,18 +4,18 @@ import asyncio
from datetime import timedelta
from ipaddress import IPv4Address, IPv6Address
import logging
from typing import Literal
from typing import TYPE_CHECKING, Literal
import aiodns
from aiodns.error import DNSError
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DnsIPConfigEntry
from .const import (
CONF_HOSTNAME,
CONF_IPV4,
@@ -46,7 +46,7 @@ def sort_ips(ips: list, querytype: Literal["A", "AAAA"]) -> list:
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: DnsIPConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the dnsip sensor entry."""
@@ -54,16 +54,29 @@ async def async_setup_entry(
hostname = entry.data[CONF_HOSTNAME]
name = entry.data[CONF_NAME]
nameserver_ipv4 = entry.options[CONF_RESOLVER]
nameserver_ipv6 = entry.options[CONF_RESOLVER_IPV6]
port_ipv4 = entry.options[CONF_PORT]
port_ipv6 = entry.options[CONF_PORT_IPV6]
entities = []
if entry.data[CONF_IPV4]:
entities.append(WanIpSensor(name, hostname, nameserver_ipv4, False, port_ipv4))
entities.append(
WanIpSensor(
entry,
name,
hostname,
entry.options[CONF_RESOLVER],
False,
entry.options[CONF_PORT],
)
)
if entry.data[CONF_IPV6]:
entities.append(WanIpSensor(name, hostname, nameserver_ipv6, True, port_ipv6))
entities.append(
WanIpSensor(
entry,
name,
hostname,
entry.options[CONF_RESOLVER_IPV6],
True,
entry.options[CONF_PORT_IPV6],
)
)
async_add_entities(entities, update_before_add=True)
@@ -75,10 +88,9 @@ class WanIpSensor(SensorEntity):
_attr_translation_key = "dnsip"
_unrecorded_attributes = frozenset({"resolver", "querytype", "ip_addresses"})
resolver: aiodns.DNSResolver
def __init__(
self,
entry: DnsIPConfigEntry,
name: str,
hostname: str,
nameserver: str,
@@ -86,6 +98,8 @@ class WanIpSensor(SensorEntity):
port: int,
) -> None:
"""Initialize the DNS IP sensor."""
self.entry = entry
self.ipv6 = ipv6
self._attr_name = "IPv6" if ipv6 else None
self._attr_unique_id = f"{hostname}_{ipv6}"
self.hostname = hostname
@@ -104,28 +118,43 @@ class WanIpSensor(SensorEntity):
model=aiodns.__version__,
name=name,
)
self.create_dns_resolver()
@property
def _resolver(self) -> aiodns.DNSResolver:
"""Return the active DNS resolver from runtime data."""
resolver = (
self.entry.runtime_data.resolver_ipv6
if self.ipv6
else self.entry.runtime_data.resolver_ipv4
)
if TYPE_CHECKING:
assert resolver is not None
return resolver
def create_dns_resolver(self) -> None:
"""Create the DNS resolver."""
self.resolver = aiodns.DNSResolver(
"""Create a new DNS resolver and store it on runtime data."""
new_resolver = aiodns.DNSResolver(
nameservers=[self.nameserver], tcp_port=self.port, udp_port=self.port
)
if self.ipv6:
self.entry.runtime_data.resolver_ipv6 = new_resolver
else:
self.entry.runtime_data.resolver_ipv4 = new_resolver
async def async_update(self) -> None:
"""Get the current DNS IP address for hostname."""
if self.resolver._closed: # noqa: SLF001
if self._resolver._closed: # noqa: SLF001
self.create_dns_resolver()
response = None
try:
async with asyncio.timeout(10):
response = await self.resolver.query(self.hostname, self.querytype)
response = await self._resolver.query(self.hostname, self.querytype)
except TimeoutError as err:
_LOGGER.debug("Timeout while resolving host: %s", err)
await self.resolver.close()
await self._resolver.close()
except DNSError as err:
_LOGGER.warning("Exception while resolving host: %s", err)
await self.resolver.close()
await self._resolver.close()
if response:
sorted_ips = sort_ips(
+26 -17
View File
@@ -3,13 +3,12 @@
from http import HTTPStatus
import os
import re
import threading
import requests
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path
@@ -29,8 +28,8 @@ from .const import (
)
def download_file(service: ServiceCall) -> None:
"""Start thread to download file specified in the URL."""
async def download_file(service: ServiceCall) -> None:
"""Download file specified in the URL."""
entry = service.hass.config_entries.async_loaded_entries(DOMAIN)[0]
download_path = entry.data[CONF_DOWNLOAD_DIR]
@@ -124,18 +123,7 @@ def download_file(service: ServiceCall) -> None:
{"url": url, "filename": filename},
)
except requests.exceptions.ConnectionError:
_LOGGER.exception("ConnectionError occurred for %s", url)
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
)
# Remove file if we started downloading but failed
if final_path and os.path.isfile(final_path):
os.remove(final_path)
except ValueError:
_LOGGER.exception("Invalid value")
except requests.exceptions.ConnectionError as err:
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
@@ -145,7 +133,28 @@ def download_file(service: ServiceCall) -> None:
if final_path and os.path.isfile(final_path):
os.remove(final_path)
threading.Thread(target=do_download).start()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={"url": url},
) from err
except ValueError as err:
service.hass.bus.fire(
f"{DOMAIN}_{DOWNLOAD_FAILED_EVENT}",
{"url": url, "filename": filename},
)
# Remove file if we started downloading but failed
if final_path and os.path.isfile(final_path):
os.remove(final_path)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_value",
translation_placeholders={"url": url},
) from err
await service.hass.async_add_executor_job(do_download)
@callback
@@ -13,6 +13,12 @@
}
},
"exceptions": {
"connection_error": {
"message": "Connection error occurred while downloading {url}"
},
"invalid_value": {
"message": "Invalid filename derived from {url}"
},
"subdir_invalid": {
"message": "Invalid subdirectory, got: {subdir}"
},
@@ -4,6 +4,7 @@
CONF_COMMAND_TOPIC = "drop_command_topic"
CONF_DATA_TOPIC = "drop_data_topic"
CONF_DEVICE_DESC = "device_desc"
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEVICE_ID = "device_id"
CONF_DEVICE_TYPE = "device_type"
CONF_HUB_ID = "drop_hub_id"
@@ -49,6 +49,7 @@ CURRENT_SYSTEM_PRESSURE = "current_system_pressure"
HIGH_SYSTEM_PRESSURE = "high_system_pressure"
LOW_SYSTEM_PRESSURE = "low_system_pressure"
BATTERY = "battery"
# pylint: disable-next=home-assistant-duplicate-const
TEMPERATURE = "temperature"
INLET_TDS = "inlet_tds"
OUTLET_TDS = "outlet_tds"
+1 -2
View File
@@ -11,7 +11,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
create_rfxtrx_tcp_dsmr_reader,
)
from dsmr_parser.objects import DSMRObject
import serial
import voluptuous as vol
from homeassistant.components import usb
@@ -117,7 +116,7 @@ class DSMRConnection:
try:
transport, protocol = await asyncio.create_task(reader_factory())
except serial.SerialException, OSError:
except OSError:
LOGGER.exception("Error connecting to DSMR")
return False
+1 -1
View File
@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
"requirements": ["dsmr-parser==1.5.0"]
"requirements": ["dsmr-parser==1.7.0"]
}
+1 -2
View File
@@ -15,7 +15,6 @@ from dsmr_parser.clients.rfxtrx_protocol import (
create_rfxtrx_tcp_dsmr_reader,
)
from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
import serial
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
@@ -846,7 +845,7 @@ async def async_setup_entry(
# throttle reconnect attempts
await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL)
except serial.SerialException, OSError:
except OSError:
# Log any error while establishing connection and drop to retry
# connection wait
LOGGER.exception("Error connecting to DSMR")
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"quality_scale": "platinum",
"requirements": ["python-duco-connectivity==0.4.0"],
"requirements": ["python-duco-connectivity==0.5.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
+87 -29
View File
@@ -5,7 +5,14 @@ from enum import StrEnum
from functools import partial
from typing import Final
from easyenergy import Electricity, Gas, PriceInterval, VatOption
from easyenergy import (
Electricity,
ElectricityGranularity,
ElectricityPriceType,
Gas,
PriceInterval,
VatOption,
)
from easyenergy.const import MARKET_TIMEZONE
import voluptuous as vol
@@ -27,29 +34,15 @@ ATTR_CONFIG_ENTRY: Final = "config_entry"
ATTR_START: Final = "start"
ATTR_END: Final = "end"
ATTR_INCL_VAT: Final = "incl_vat"
ATTR_GRANULARITY: Final = "granularity"
ATTR_PRICE_TYPE: Final = "price_type"
GAS_SERVICE_NAME: Final = "get_gas_prices"
ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices"
ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices"
BASE_SERVICE_SCHEMA: Final = {
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
vol.Optional(ATTR_START): str,
vol.Optional(ATTR_END): str,
}
SERVICE_SCHEMA: Final = vol.Schema(
{
**BASE_SERVICE_SCHEMA,
vol.Required(ATTR_INCL_VAT): bool,
}
)
RETURN_SERVICE_SCHEMA: Final = vol.Schema(BASE_SERVICE_SCHEMA)
class PriceType(StrEnum):
class ServicePriceType(StrEnum):
"""Type of price."""
ENERGY_USAGE = "energy_usage"
@@ -57,6 +50,52 @@ class PriceType(StrEnum):
GAS = "gas"
GRANULARITY_OPTIONS: Final = tuple(
granularity.value for granularity in ElectricityGranularity
)
PRICE_TYPE_OPTIONS: Final = tuple(
electricity_price_type.value for electricity_price_type in ElectricityPriceType
)
BASE_SERVICE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
vol.Optional(ATTR_START): str,
vol.Optional(ATTR_END): str,
}
)
GAS_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
{
vol.Required(ATTR_INCL_VAT): bool,
vol.Optional(
ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value
): vol.In(PRICE_TYPE_OPTIONS),
}
)
ENERGY_USAGE_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
{
vol.Required(ATTR_INCL_VAT): bool,
vol.Optional(
ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value
): vol.In(GRANULARITY_OPTIONS),
vol.Optional(
ATTR_PRICE_TYPE, default=ElectricityPriceType.MARKET.value
): vol.In(PRICE_TYPE_OPTIONS),
}
)
ENERGY_RETURN_SERVICE_SCHEMA: Final = BASE_SERVICE_SCHEMA.extend(
{
vol.Optional(
ATTR_GRANULARITY, default=ElectricityGranularity.HOUR.value
): vol.In(GRANULARITY_OPTIONS),
}
)
def __get_date(
date_input: str | None,
) -> tuple[date, datetime | None]:
@@ -113,6 +152,19 @@ def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResp
}
def __select_prices(
data: Electricity | Gas, use_invoice: bool
) -> list[dict[str, float | datetime]]:
"""Select market or invoice prices from price data."""
if not use_invoice:
return data.timestamp_prices
return [
{"timestamp": interval.starts_at, "price": interval.invoice_price}
for interval in data.intervals
]
def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
"""Get the coordinator from the entry."""
entry: EasyEnergyConfigEntry = service.async_get_config_entry(
@@ -124,7 +176,7 @@ def __get_coordinator(call: ServiceCall) -> EasyEnergyDataUpdateCoordinator:
async def __get_prices(
call: ServiceCall,
*,
price_type: PriceType,
service_price_type: ServicePriceType,
) -> ServiceResponse:
"""Get prices from easyEnergy."""
coordinator = __get_coordinator(call)
@@ -137,23 +189,29 @@ async def __get_prices(
vat = VatOption.EXCLUDE
data: Electricity | Gas
prices: list[dict[str, float | datetime]]
if price_type == PriceType.GAS:
if service_price_type == ServicePriceType.GAS:
data = await coordinator.easyenergy.gas_prices(
start_date=start_date,
end_date=end_date,
vat=vat,
)
prices = data.timestamp_prices
prices = __select_prices(
data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value
)
else:
data = await coordinator.easyenergy.energy_prices(
start_date=start_date,
end_date=end_date,
granularity=ElectricityGranularity(call.data[ATTR_GRANULARITY]),
vat=vat,
)
if price_type == PriceType.ENERGY_USAGE:
prices = data.timestamp_prices
if service_price_type == ServicePriceType.ENERGY_USAGE:
prices = __select_prices(
data, call.data[ATTR_PRICE_TYPE] == ElectricityPriceType.INVOICE.value
)
else:
prices = data.timestamp_return_prices
@@ -181,21 +239,21 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
GAS_SERVICE_NAME,
partial(__get_prices, price_type=PriceType.GAS),
schema=SERVICE_SCHEMA,
partial(__get_prices, service_price_type=ServicePriceType.GAS),
schema=GAS_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
ENERGY_USAGE_SERVICE_NAME,
partial(__get_prices, price_type=PriceType.ENERGY_USAGE),
schema=SERVICE_SCHEMA,
partial(__get_prices, service_price_type=ServicePriceType.ENERGY_USAGE),
schema=ENERGY_USAGE_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
hass.services.async_register(
DOMAIN,
ENERGY_RETURN_SERVICE_NAME,
partial(__get_prices, price_type=PriceType.ENERGY_RETURN),
schema=RETURN_SERVICE_SCHEMA,
partial(__get_prices, service_price_type=ServicePriceType.ENERGY_RETURN),
schema=ENERGY_RETURN_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -10,6 +10,15 @@ get_gas_prices:
default: true
selector:
boolean:
price_type:
required: false
default: market
selector:
select:
translation_key: price_type_selector
options:
- market
- invoice
start:
required: false
example: "2024-01-01 00:00:00"
@@ -32,6 +41,24 @@ get_energy_usage_prices:
default: true
selector:
boolean:
granularity:
required: false
default: hour
selector:
select:
translation_key: granularity_selector
options:
- hour
- quarter
price_type:
required: false
default: market
selector:
select:
translation_key: price_type_selector
options:
- market
- invoice
start:
required: false
example: "2024-01-01 00:00:00"
@@ -49,6 +76,15 @@ get_energy_return_prices:
selector:
config_entry:
integration: easyenergy
granularity:
required: false
default: hour
selector:
select:
translation_key: granularity_selector
options:
- hour
- quarter
start:
required: false
example: "2024-01-01 00:00:00"
@@ -54,6 +54,20 @@
"message": "Invalid date provided. Got {date}"
}
},
"selector": {
"granularity_selector": {
"options": {
"hour": "Hour",
"quarter": "Quarter"
}
},
"price_type_selector": {
"options": {
"invoice": "All-in price",
"market": "Market price"
}
}
},
"services": {
"get_energy_return_prices": {
"description": "Requests return energy prices from easyEnergy.",
@@ -66,6 +80,10 @@
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]"
},
"granularity": {
"description": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::description%]",
"name": "[%key:component::easyenergy::services::get_energy_usage_prices::fields::granularity::name%]"
},
"start": {
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]"
@@ -84,10 +102,18 @@
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]"
},
"granularity": {
"description": "The interval size for the electricity prices.",
"name": "Granularity"
},
"incl_vat": {
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]"
},
"price_type": {
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::price_type::name%]"
},
"start": {
"description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]",
"name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]"
@@ -110,6 +136,10 @@
"description": "Whether the prices should include VAT.",
"name": "VAT included"
},
"price_type": {
"description": "The type of prices to retrieve.",
"name": "Price type"
},
"start": {
"description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted.",
"name": "Start"
@@ -1,8 +1,10 @@
"""Constants for the ElevenLabs text-to-speech integration."""
# pylint: disable-next=home-assistant-duplicate-const
ATTR_MODEL = "model"
CONF_VOICE = "voice"
# pylint: disable-next=home-assistant-duplicate-const
CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability"
@@ -43,6 +43,7 @@ DISPLAY_MESSAGE_SERVICE_SCHEMA: VolDictType = {
}
SERVICE_ALARM_DISPLAY_MESSAGE = "alarm_display_message"
# pylint: disable-next=home-assistant-duplicate-const
SERVICE_ALARM_ARM_VACATION = "alarm_arm_vacation"
SERVICE_ALARM_ARM_HOME_INSTANT = "alarm_arm_home_instant"
SERVICE_ALARM_ARM_NIGHT_INSTANT = "alarm_arm_night_instant"
@@ -32,6 +32,7 @@ from .discovery import (
async_update_entry_from_discovery,
)
# pylint: disable-next=home-assistant-duplicate-const
CONF_DEVICE = "device"
NON_SECURE_PORT = 2101

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