Compare commits

...

429 Commits

Author SHA1 Message Date
Jan Čermák 9aa9278eec Bump base image to 2026.02.0 with Python 3.14.3, use 3.14.3 in CI
This also bumps libcec used in the base image to 7.1.1, full changelog:
* https://github.com/home-assistant/docker/releases/tag/2026.02.0

Python changelog:
* https://docs.python.org/release/3.14.3/whatsnew/changelog.html
2026-04-29 09:10:20 +02:00
Erik Montnemery 05121b89c6 Unload scripts and conditions created by automations (#169362) 2026-04-29 09:03:34 +02:00
Tomer 326895f0a1 Victron GX: Platinum quality scale (#169070) 2026-04-29 09:02:39 +02:00
Brett Adams 45121eddf1 Use new console pages for vehicles and energy sites in Teslemetry (#168865) 2026-04-29 09:00:39 +02:00
Konrad Strack 5e4f8f8bff Fix missing hue.activate_scene actions (#168859) 2026-04-29 08:59:04 +02:00
Klaas Schoute b9bbe36af0 Remove name field from Forecast.solar config flow (#169165) 2026-04-29 08:58:22 +02:00
epenet b56cdb9106 Fix flaky unifi device_tracker entity race on setup (#169359) 2026-04-29 08:55:10 +02:00
epenet e975496145 Fix flaky test_tasks_logged_that_block_stage_2 with Python 3.14.3 (#169424)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:54:21 +02:00
Simone Chemelli cdeb550b87 Use new UPTIME sensor class for Synology DSM (#169090) 2026-04-29 08:52:53 +02:00
Erik Montnemery 62082bdf14 Use modern condition API in script helper (#169355) 2026-04-29 08:51:57 +02:00
ibrahim amous 891efeb9cb Use enumerate instead of range(len()) in Duco fan speed list (#169392) 2026-04-29 08:51:26 +02:00
Daniel Hjelseth Høyer dc8abff6b9 Improve data updating for Tibber (#168065)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-04-29 08:42:47 +02:00
epenet aa7474839b Fix watts coordinator interrupting fast polling on hub update (#169365)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:39:58 +02:00
Robert Svensson 06a96712f6 Bump axis to v69 (#169408) 2026-04-29 08:27:43 +02:00
Kurt Chrisford 97be8f485a Add DRY HVAC mode support to Actron Air based on hardware capabilities (#169132) 2026-04-29 08:26:03 +02:00
Florent Thoumie a9c23ff445 iaqualink: add reconfigure flow (#169412) 2026-04-29 08:24:57 +02:00
Brett Adams cd92cb1258 Filter out "Unknown" part_name from Teslemetry energy device model (#169413)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:23:52 +02:00
Erik Montnemery c3f01b3a23 Unload scripts created by wake_on_lan switch (#169367) 2026-04-29 08:22:00 +02:00
Erik Montnemery 4b232be04a Unload scripts created by intent_script (#169363) 2026-04-29 08:21:26 +02:00
Robert Svensson cd5e21d3ac Allow Axis websocket event usage if supported (#169409) 2026-04-29 08:19:19 +02:00
Abílio Costa 84d5085f3b Add path-specific custom instructions to copilot gen script (#169402) 2026-04-29 08:19:10 +02:00
Erik Montnemery 44e94a82f1 Add last_action state attribute to timers (#168282) 2026-04-29 08:16:19 +02:00
Christian Lackas fe0da5c34f Bump PyViCare to 2.60.1 (#169401) 2026-04-29 08:14:39 +02:00
epenet c0200084ec Fix flaky test_alexa_config_expose_entity_prefs with Python 3.14.3 (#169421)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:13:14 +02:00
Simone Chemelli ef63ab5def Use new UPTIME sensor class for Vodafone Station (#169077)
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 08:12:05 +02:00
Michael 3683607820 Deprecate firmware update button in FRITZ!Box Tools (#168117) 2026-04-29 08:10:29 +02:00
epenet 4c70fef2da Fix flaky mcp_server tests with Python 3.14.3 (#169385) 2026-04-28 22:13:06 -07:00
Nicolas Mowen d956af095e Add ability to filter GetLiveContext tool (#168457)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 22:08:29 -07:00
Brett Adams ea34fe4107 Bump Tesla Fleet API to 1.4.7 (#169411) 2026-04-29 01:25:04 +02:00
oxidworks e1c81c9b9e Reword country_not_configured repair description (#168357) 2026-04-29 00:55:48 +02:00
Mike Degatano 4ea0e6b240 Require admin for supervisor event publishing and addon options info (#169325)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Stefan Agner <stefan@agner.ch>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 18:51:16 -04:00
TheJulianJES 0ae5a19602 Handle ZHA dynamic entity add/remove events (#169341) 2026-04-28 23:48:57 +02:00
Raphael Hehl 80c7e47c42 Migrate UniFi Network discovery from SSDP to unifi_discovery (#168122)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-28 23:17:36 +02:00
Paul Bottein dfe4085189 Add fan platform to Novy Cooker Hood (#169380) 2026-04-28 16:51:46 -04:00
Paul Bottein 65a12b48e7 Add Novy Cooker Hood integration (#169194) 2026-04-28 16:24:19 -04:00
Joost Lekkerkerker cd639b829c Add battery mode select to Zinvolt (#169397) 2026-04-28 21:16:23 +02:00
Paulus Schoutsen ea5b633574 Bump rf-protocols to 2.2.0 (#169400)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-28 21:11:02 +02:00
Paulus Schoutsen 2f2413c979 Enforce per-entity permissions in calendar HTTP and WS APIs (#169235)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 15:06:49 -04:00
Ludovic BOUÉ 799bcb0f88 Fix Matter electrical sensors wrongly categorized as diagnostic (#169208)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-28 20:56:26 +02:00
Erik Montnemery d3cf5d9aab Add duration support to cover conditions (#169346) 2026-04-28 20:50:41 +02:00
puddly d2fddf129d Include matching integrations in scanned ports WS API (#169387) 2026-04-28 14:50:31 -04:00
AlCalzone d19c2506bf Discover Fibaro FGMS001 v2.8 as a motion sensor for Z-Wave (#169276) 2026-04-28 20:45:12 +02:00
Øyvind Matheson Wergeland 8fd3d0bb44 Fix nobo_hub KeyError when a zone or component is removed (#169378) 2026-04-28 20:30:53 +02:00
Ludovic BOUÉ d62f136c58 Add child lock entity for Eve Matter devices (#169391) 2026-04-28 19:55:37 +02:00
Ludovic BOUÉ 86e8b9df9b Add temporary mute button for Heiman smoke detector (#169311)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-28 18:19:22 +01:00
epenet aa5e942528 Fix flaky gardena_bluetooth test_timeout_manufacturer_data (#169389) 2026-04-28 19:15:43 +02:00
epenet 6636e67af6 Fix flaky cloud TTS and picotts streaming tests (#169376)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:38:03 +02:00
Raphael Hehl 30f310fc24 Add UniFi Protect relay output switches via public API (#169201)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-28 18:34:58 +02:00
Paulus Schoutsen de4e1c444e Restrict homematic.set_install_mode service to admins (#169203)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-04-28 17:00:13 +01:00
Raphael Hehl eaf72106f8 Add siren platform to UniFi Protect integration (#169216)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 17:50:02 +02:00
A. Gideonse b90a074fb4 Bump indevolt-api to 1.6.4 (#169377) 2026-04-28 16:12:09 +01:00
epenet 3aea7f0695 Fix flaky stream test_stream_retries (#169372)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:41:21 +02:00
epenet ba8b1b2daf Fix flaky google calendar tests (#169371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 15:21:38 +02:00
Erik Montnemery 5ff1c15df3 Unload scripts created by script entities (#169364) 2026-04-28 15:02:09 +02:00
Zoltán Farkasdi 0280d921e5 netatmo: add battery sensor for doortags (#168202) 2026-04-28 14:22:26 +02:00
Petro31 955e8362e4 Add template number device_class (#168438) 2026-04-28 14:12:18 +02:00
Ariel Ebersberger 1fc0b620c0 Fix flaky template test (#169361) 2026-04-28 14:09:07 +02:00
puddly ab08153d62 Expose more port metadata when listing serial ports (#169336) 2026-04-28 07:57:13 -04:00
Robert Resch b47b7fa58c User camera unique id in go2rtc if available (#168603)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:39:55 +02:00
Kurt Chrisford c50676dee9 Bump actron-neo-api requirement to version 0.5.6 (#169357) 2026-04-28 13:17:10 +02:00
Erik Montnemery 96bd991bb8 Use modern condition API in condition helper tests (#169353) 2026-04-28 13:04:21 +02:00
epenet 7e2a7b9393 Fix shelly device_trigger tests (#169305)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 13:50:02 +03:00
Erik Montnemery eb2217cfa6 Use modern condition API in condition tests (#169354) 2026-04-28 12:04:29 +02:00
Matrix b2269b3dba Bump yolink-api to 0.6.5 (#169350) 2026-04-28 11:59:23 +02:00
Abílio Costa eb85d7cd98 Allow targeting non-primary entities in triggers (#168857) 2026-04-28 11:49:20 +02:00
Robert Resch 6663717d59 Require local_only to be a boolean on webhook (#169296)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 11:40:49 +02:00
Mike Degatano 33e5a96a57 Require admin on APIs to create/delete config entries from Supervisor discovery (#169340) 2026-04-28 11:38:39 +02:00
Mike Degatano 7cb4d5ca9c Require admin for addon panel register and delete (#169329) 2026-04-28 11:38:29 +02:00
epenet 7dacd0080b Fix otbr flaky config flow tests in Python 3.14.3 (#169348)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:22:15 +02:00
Franck Nijhof 7f44fe031c Update gotailwind to v0.4.0 (#169316) 2026-04-28 10:54:05 +02:00
epenet 9656aaa6bd Fix Withings via_device race causing flaky test_devices (#169347)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:52:59 +02:00
Leonardo Rivera bf4b865e83 Fix OneDrive upload service to report all missing files at once (#169221) 2026-04-28 10:31:08 +02:00
Erik Montnemery 73dcc2f5a8 Add missing call to ConditionChecker.async_setup in async_from_config (#169055) 2026-04-28 10:30:11 +02:00
Ronald van der Meer fa0cf37e2c Fix Duco diagnostics crash on connection error (#169322) 2026-04-28 10:25:18 +02:00
epenet fa6c6ee4fc Fix bang olufsen flaky tests in Python 3.14.3 (#169345)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 09:23:31 +02:00
Erik Montnemery 4eb000d863 Add state tracking to EntityConditionBase (#169030) 2026-04-28 09:14:46 +02:00
Artur Pragacz d3809dd4cb Improve error handling for vacuum clean_area (#168177) 2026-04-28 09:14:17 +02:00
karwosts 2f3a6243f7 Restore state_class to derivative sensor (#163557) 2026-04-28 09:09:21 +02:00
epenet f36799d139 Refactor timezone handling in mqtt datetime (#169342)
Co-authored-by: Copilot <copilot@github.com>
2026-04-28 09:08:49 +02:00
Erik Montnemery 8d5f83e5f1 Add explicit test of conditions.async_conditions_from_config (#169042) 2026-04-28 08:24:43 +02:00
A. Gideonse 308cb686d2 Refactor indevolt integration to remove magic number constants (#169337)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 07:51:04 +02:00
frantzju 63d4f4d03d Bump freebox-api to 1.3.1 (#169335) 2026-04-28 01:08:57 +02:00
A. Gideonse d8a4b36381 Bump indevolt-api to 1.6.3 (#169338) 2026-04-28 00:22:31 +02:00
Florent Thoumie c048af2e4e Update to iaqualink 0.7.0 (#169330) 2026-04-27 22:27:38 +01:00
Manu 1a25864890 Bump aiontfy to 0.8.5 (#169327) 2026-04-27 23:13:14 +02:00
Thomas55555 d1922189aa Bump aioautomower to 2.7.4 (#169331) 2026-04-27 23:02:15 +02:00
Miko Stern 7594ead857 Add departure_delay sensor to Israel Rail (#169035) 2026-04-27 20:19:14 +01:00
puddly 8ce14877a4 Register a stub transport for ESPHome serial proxies via USB (#169308) 2026-04-27 15:12:07 -04:00
A. Gideonse 2fb0de3cdb Add actions to Indevolt integration (#163578)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-27 19:46:28 +01:00
Stefan Agner 3e77a4bfb2 Remove unused X-Hass constants (#169324) 2026-04-27 20:35:52 +02:00
puddly 4b9dd68fe7 Remove unnecessary title assertion from Yellow integration tests (#169320) 2026-04-27 20:22:33 +02:00
Stefan Agner f21ed9054b Remove hassio onboarding bypass for backup endpoints (#169299)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:37:06 -04:00
puddly 53e4d6c8fc Migrate ZHA to SerialPortSelector (#169099) 2026-04-27 13:03:54 -04:00
MohamedBarrak3 6a67c0faf7 Fix grammatical error in synology_dsm missing_data error message (#169306) 2026-04-27 18:17:25 +02:00
epenet d590f4f0b5 Fix more Shelly tests for Python 3.14.3 (#169304) 2026-04-27 18:16:43 +02:00
epenet 45a6134209 Wait for background tasks in shelly tests (#169301) 2026-04-27 18:40:49 +03:00
Paulus Schoutsen 6893d2b13d Migrate remaining top-level async_register_command handlers to decorators (#169227)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 17:29:24 +02:00
Paulus Schoutsen 370babf542 Require admin for diagnostics download and application credentials list (#169234) 2026-04-27 17:24:28 +02:00
epenet 6c89ecb98b Improve subscription handling in shelly tests (#169293)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 18:17:22 +03:00
Simone Chemelli cc1eaa72a6 Bump aiocomelit to 2.0.3 (#169300) 2026-04-27 17:12:00 +02:00
Paulus Schoutsen 21a3c5b0ed Require admin for logger.set_level and logger.set_default_level services (#169232)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:03:03 +02:00
Paulus Schoutsen 080eb6af84 Require admin for backup.create and backup.create_automatic services (#169228)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 17:01:30 +02:00
Paulus Schoutsen 663538c492 Require admin for cloud preference and support package endpoints (#169205) 2026-04-27 16:59:24 +02:00
Paulus Schoutsen d119bbe4ef Require admin for configurator.configure service (#169230)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:56:47 +02:00
Ronald van der Meer 0eb204508c Bump python-duco-client to 0.3.9 (#169174) 2026-04-27 15:58:03 +02:00
Peter Grauvogel ad836b48b0 Fix Green Planet Energy timestamp sensors (#166153)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-04-27 15:41:31 +02:00
epenet 9cc9f240e7 Raise ConfigEntryError on decora_wifi authentication error (#169285) 2026-04-27 15:19:29 +02:00
epenet 91d5c080de Add missing mock in izone tests (#169287) 2026-04-27 15:18:12 +02:00
epenet 9390bf3414 Fix incorrect CONF_ID in twinkly tests (#169284) 2026-04-27 15:16:35 +02:00
Matthias Alphart a5b65766db Fix flaky KNX test (#169288) 2026-04-27 15:11:57 +02:00
epenet 9321ff504c Fix missing mock in switcher_kis config flow test (#169286)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-27 16:07:58 +03:00
Robert Resch 8a22e84db0 Verify local_only webhook on MockRequest (#169271)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 14:49:48 +02:00
Paulus Schoutsen 642206699d Require admin for management WebSocket commands (#169209)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:34:15 -04:00
epenet 5d98f467fb Migrate esphome to use HassKey (#168873)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 14:16:29 +02:00
Paulus Schoutsen 54727a6f20 Require admin access for hassio Supervisor services (#169226)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:06:40 -04:00
Michael ed371bc644 Deprecate yaml import in Shopping List (#169084) 2026-04-27 13:04:11 +01:00
Manu 3cc6cc9519 Set integration type helper in OTP integration (#169012) 2026-04-27 12:52:02 +02:00
Denis Shulyaka 2053e61a80 Refactor anthropic stream processing (#168980) 2026-04-27 12:51:48 +02:00
Allen Porter 3673a80a37 Fix Google Generative AI token usage statistics tracing (#169222) 2026-04-27 12:48:32 +02:00
wollew 0cc531e333 Remove unused line of code in Velux integration (#168987) 2026-04-27 12:43:50 +02:00
Øyvind Matheson Wergeland 12280dbe63 Add sensor platform tests for nobo_hub (#169008) 2026-04-27 12:43:09 +02:00
Maciej Bieniek 84a5ba26d3 Remove unused name string from AccuWeather strings (#169195) 2026-04-27 12:38:28 +02:00
epenet 6ec4466ad7 Remove deprecated action from google generative AI (#169255) 2026-04-27 12:36:51 +02:00
epenet cb62562f5b Migrate waze_travel_time to use HassKey for shared semaphore (#169264)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:36:37 +02:00
Franck Nijhof a381a3a741 Update vehicle to 3.0.0 (#169127) 2026-04-27 12:24:48 +02:00
G Johansson 6902504087 Bump holidays lib to 0.95 (#169199) 2026-04-27 11:59:27 +02:00
Manu 64f2fa42fc Add integration type service to HTML5 Push notifications (#169015) 2026-04-27 11:56:37 +02:00
Franck Nijhof 64c9a76fc8 Add binary sensor platform to Fumis integration (#169032)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-27 11:53:25 +02:00
Franck Nijhof e9fc6b3e74 Merge branch 'master' into dev 2026-04-27 09:50:23 +00:00
epenet 605eea6274 Migrate yeelight to use HassKey for shared custom effects (#169268)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:31:35 +02:00
Paulus Schoutsen 86af61d7b5 Generalize radio_frequency test fixtures in components conftest (#169098)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-27 11:25:25 +02:00
epenet 45978f41cd Use runtime_data in xiaomi_aqara (#169262) 2026-04-27 11:18:32 +02:00
epenet 4d4e45854f Use HassKey in wemo (#169261) 2026-04-27 11:09:39 +02:00
epenet 43fa4f2646 Use runtime_data in yeelight (#169260) 2026-04-27 10:52:32 +02:00
epenet 5cedb0b726 Apply Tuya device quirks on device registration (#168897)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 10:51:40 +02:00
epenet e3de695b99 Fix openai_conversation tests broken by Python 3.14.3 asyncio changes (#169259) 2026-04-27 10:27:01 +02:00
epenet e684490219 Fix google_generative_ai_conversation tests broken by Python 3.14.3 asyncio changes (#169258) 2026-04-27 10:20:49 +02:00
Paulus Schoutsen 70947c612c Require admin for fritz.set_guest_wifi_password service (#169233)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 08:55:58 +02:00
epenet 07c144841f Cleanup deprecated SEND_PIN service in blink (#169253) 2026-04-27 08:50:51 +02:00
G Johansson 392c46c028 Remove unique id in dnsip (#169211)
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 08:47:24 +02:00
Paulus Schoutsen 5af3b361c8 Enforce admin policy for assist_satellite ask_question service (#169231)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 06:41:22 +02:00
Paulus Schoutsen 040c960ced Require admin for deCONZ services (#169207)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:33:42 -04:00
Paulus Schoutsen f2787115d0 Restrict file.read_file service to admin users (#169204) 2026-04-26 20:33:11 -04:00
Paulus Schoutsen 2dd1632fc3 Restrict Insteon link management services to admins (#169206)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:32:44 -04:00
Manu ed1aefc643 Bump pyloadapi to 2.1.0 (#169215) 2026-04-26 22:11:21 +02:00
Maciej Bieniek f1bbe4204b Use the carbon monoxide device class in the Airly integration (#169193)
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 17:53:01 +02:00
Luke Lashley 929379799c Add cleaning route for Q7 vacuums (#169177) 2026-04-26 08:42:15 -07:00
Daniel Feinberg d20d1df382 Add reset buttons for dock strainer and maintenance brush consumables in Roborock (#167395)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 08:41:18 -07:00
Raphael Hehl b5a1b592e9 Bump uiprotect to 10.4.1 (#169192)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-26 09:44:41 -05:00
Raphael Hehl 6384e6b38d Add alarm control panel platform to UniFi Protect (#169158)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-26 08:45:08 -05:00
Klaas Schoute cd98577eb7 Update easyEnergy integration to v3.0.0 (#169162) 2026-04-26 14:23:59 +02:00
Kurt Chrisford fa9a336725 Bump actron-neo-api to 0.5.5 (#169176) 2026-04-26 14:17:24 +02:00
mithomas aa0199b442 Add LG Netcast service to send remote control commands (#168649) 2026-04-26 14:14:16 +02:00
Richard Kroegel a506be4be0 Remove TARGET_TEMPERATURE_RANGE from eurotronic climate (#169182) 2026-04-26 14:09:11 +02:00
Maciej Bieniek e78a79a29e Remove name from Airly config flow (#169145)
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 13:38:50 +02:00
Paul Bottein eed4acc745 Add radio_frequency platform to Broadlink (#169128)
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-26 11:40:47 +02:00
Tom Matheussen f1fcca2c75 Bump satel_integra to 1.2.2 (#169180) 2026-04-26 11:19:05 +02:00
javicalle 8673694f6f Try to fix RFLink tests broken by Python 3.14.3 asyncio changes (#169157) 2026-04-26 10:23:56 +02:00
Gustav Åkerström e8e9914ef5 Template vacuum segments (#167805)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-26 03:33:41 +02:00
Jan Bouwhuis 77c7225750 Fix None is not and allowed Unit of Measurement during MQTT Device setup via the UI (#169173) 2026-04-26 00:47:34 +02:00
Jan Bouwhuis 595f041143 Add MQTT datetime platform (#169091) 2026-04-26 00:45:11 +02:00
Franck Nijhof 2c4f598c06 Add button platform to Fumis integration (#169095) 2026-04-26 00:35:48 +02:00
Michael 1bf77e095d Migrate refoss to use entry.runtime_data (#169105) 2026-04-26 00:18:35 +02:00
Andres Ruiz d832abc5fc Add climate entity to Waterfurnace (#168729) 2026-04-26 00:17:12 +02:00
Jordi e7dae028ba Bump aioaquacell to 1.0.0 (#169166) 2026-04-26 00:15:01 +02:00
EnjoyingM e19d0e75c3 Wolflink: Fixing Codeowner (#169171) 2026-04-26 00:14:11 +02:00
Samuel Xiao 306fc529f2 Switchbot_BLE: bump PySwitchbot to 2.2.0 (#169119) 2026-04-26 00:01:14 +02:00
Marc Mueller c1894eda83 Detect .start entry point files in hassfest check (#169135) 2026-04-25 23:55:47 +02:00
mayerwin e9ca9254df Preserve sub-meter GPS accuracy in mobile_app webhooks (#169144)
Co-authored-by: mayerwin <2272127+mayerwin@users.noreply.github.com>
2026-04-25 23:51:56 +02:00
Ronald van der Meer 9ccc2e7473 Add temperature sensor to Duco integration (#169021) 2026-04-25 23:49:17 +02:00
Raphael Hehl d1bdd6eeeb Upgrade UniFi Network integration quality scale to Silver (#168736)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-25 23:44:37 +02:00
Franck Nijhof 8e3070afe1 Add reconfiguration flow to PVOutput (#169123) 2026-04-25 17:19:11 -04:00
Maciej Bieniek c48502afda Remove name from AccuWeather config flow (#169142)
Co-authored-by: Copilot <copilot@github.com>
2026-04-25 23:12:14 +02:00
Øyvind Matheson Wergeland 77df31fa83 Add climate platform tests for nobo_hub (#169010) 2026-04-25 23:11:44 +02:00
Denis Shulyaka f06cd25f4a Add GPT-5.5 support (#169112) 2026-04-25 23:11:19 +02:00
Matthias Alphart 19ebb1da2a Update knx-frontend to 2026.4.25.155016: Add notes to UI expose (#169154) 2026-04-25 23:05:44 +02:00
Christian Lackas f225d8162b homematicip_cloud: migrate entity unique IDs to stable format (#166580)
Co-authored-by: Christian Lackas <9592452+lackas@users.noreply.github.com>
2026-04-25 23:01:00 +02:00
Mika 759ac2eacd Add battery storage data sensors to SolarEdge integration (#161722)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: it-rec <19797875+it-rec@users.noreply.github.com>
2026-04-25 23:00:49 +02:00
Raphael Hehl b474a42844 unifiprotect: bump uiprotect to 10.4.0 (#169146)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-25 12:39:40 -05:00
shbatm db76773727 Standardize ISY994 sensor units and device classes (#169017)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-25 05:44:41 -05:00
Franck Nijhof 48b650c486 Modernize RDW config flow tests (#169129) 2026-04-25 11:03:15 +02:00
Franck Nijhof 9e1c02262e Set parallel updates for Hydrawise platforms (#169101)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-25 10:47:33 +02:00
Andrew Jackson 5a79dd9d99 Bump aiomealie to 1.2.4 (#169125) 2026-04-25 10:42:16 +02:00
Franck Nijhof c3f66f9e90 Set parallel updates to 0 for Forecast.Solar (#169126) 2026-04-25 10:27:57 +02:00
Joakim Plate 77fd120cd5 Protect update coordinator callbacks (#169122) 2026-04-25 10:15:33 +02:00
Joakim Plate 1978c9772a Filter unknown values from arcam enum (#169124) 2026-04-25 10:14:10 +02:00
Franck Nijhof 6862b808ae Update fumis to v0.4.0 (#169097) 2026-04-25 09:30:44 +02:00
Manu 757deb3a1c Add reconfiguration flow to Notifications for Android TV / Fire TV (#169111) 2026-04-25 09:29:38 +02:00
Franck Nijhof 54e3c3fc9b Extract common entity base class for RDW (#169118) 2026-04-25 09:26:28 +02:00
Michael e509c9b78a Migrate onvif to use entry.runtime_data (#169106) 2026-04-25 09:23:48 +02:00
A. Gideonse 2eb9f69d1e Bump indevolt-api to 1.4.3 (#169103) 2026-04-25 03:05:49 +02:00
Franck Nijhof 2278423758 Upgrade Elgato quality scale to platinum (#169102) 2026-04-24 23:10:46 +01:00
Franck Nijhof 4625176606 Upgrade Twente Milieu quality scale to platinum (#169104) 2026-04-24 23:29:52 +02:00
Tom Wilkie e7aa672133 Register MAC address connections on Synology DSM hub device (#169085)
Signed-off-by: Tom Wilkie <tom.wilkie@gmail.com>
Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
2026-04-24 22:28:22 +02:00
Miko Stern 99185bf9a4 Bump israel-rail-api to 0.1.5 (#169094) 2026-04-24 21:11:46 +02:00
Franck Nijhof b5e66bbcd0 2026.4.4 (#169092) 2026-04-24 20:37:21 +02:00
Abílio Costa 2deb364ab0 Reinforce Python 3.14 exceptions Agent instructions (#169089) 2026-04-24 20:21:07 +02:00
Raphael Hehl 822b97d096 Upgrade unifi_access quality scale to platinum (#168204)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-24 20:20:39 +02:00
Simone Chemelli 1e0dc86eea Use new UPTIME sensor class for Shelly (#169088) 2026-04-24 20:20:00 +02:00
A. Gideonse ca70abe240 Refactor button platform to use indevolt-api 1.4.2 (#169063)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 19:11:01 +01:00
Franck Nijhof f479b0ad6a Bump version to 2026.4.4 2026-04-24 18:08:48 +00:00
Bram Kragten 458b5fe8bf Update frontend to 20260325.8 (#169076) 2026-04-24 18:07:57 +00:00
Robert Resch 9621307cb0 Validate local_only user for signed requests (#169066) 2026-04-24 18:06:48 +00:00
Maciej Bieniek 4507f9a8d8 Bump aiotractive to 1.0.3 (#169059) 2026-04-24 18:06:47 +00:00
Maciej Bieniek 19dd68b7fc Slow down Tractive API polling to avoid 429 too many requests (#169057)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 18:06:45 +00:00
Daniel Hjelseth Høyer 245b9ed4c0 Update Tibber library, 0.37.2 (#169027) 2026-04-24 18:06:44 +00:00
Robert Resch 838feef660 Validate local_only user property during ws auth phase (#168812) 2026-04-24 18:06:42 +00:00
Jan Bouwhuis ca4d36db1a Cancel and await idle_start future if the task was canceled after an IMAP connection was lost (#168662)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-04-24 18:06:41 +00:00
Joakim Plate 39b690b22c Correct state/device class for water in gardena (#168637) 2026-04-24 18:06:39 +00:00
Allen Porter ed560f0ba7 Add Roborock fan speed validation and error handling (#168623) 2026-04-24 18:06:38 +00:00
Nils Ove Erstad 0db50acb89 Fix MQTT JSON light restoring None color_mode on startup (#168608)
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2026-04-24 18:06:36 +00:00
Ariel Ebersberger 446d89aee2 Disable rflink tests broken by Python 3.14.3 asyncio changes (#169074)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 19:06:23 +01:00
Ariel Ebersberger 6fe1862d15 Disable dsmr tests broken by Python 3.14.3 asyncio changes (#169064)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 19:06:14 +01:00
Ariel Ebersberger 4f5d0a7305 Disable plex tests broken by Python 3.14.3 asyncio changes (#169069)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 19:06:11 +01:00
Simone Chemelli f84bf99105 Bump aioamazondevices to 13.4.3 (#168536) 2026-04-24 18:04:30 +00:00
Khole 7fad242ad0 Hive - Bump pyhive-integration to 1.0.9 (#168489) 2026-04-24 18:04:29 +00:00
MohamedBarrak3 fcd6f78f35 Fix case-sensitive MIME type check in Google Generative AI TTS (#168458) 2026-04-24 18:04:27 +00:00
Raj Laud 056ff957e8 Fix Victron BLE false reauth on unrecognised advertisement mode bytes (#168209)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 18:04:26 +00:00
albaintor 9cf95404cf Fixed Kodi Media Browsing (#165819) 2026-04-24 18:04:24 +00:00
Ariel Ebersberger 4d8acfa61c Disable knx tests broken by Python 3.14.3 asyncio changes (#169079)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 18:54:21 +01:00
Maciej Bieniek 9369a5dc93 Slow down Tractive API polling to avoid 429 too many requests (#169057)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 19:51:07 +02:00
WardZhou 8b2afb4e66 Add a dynamic sensitivity slider for Matter sensors (#167710)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2026-04-24 18:50:23 +01:00
Bram Kragten a53d3ea9eb Update frontend to 20260325.8 (#169076) 2026-04-24 19:50:11 +02:00
Maciej Bieniek e422c08d4e Support media player for Shelly Wall Display (#168494)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 19:49:31 +02:00
Michael 599fe252ef Fix feedreader tests broken by Python 3.14.3 asyncio changes (#169080) 2026-04-24 18:49:10 +02:00
Erik Montnemery aad93fd577 Add tests asserting condition features (#168881) 2026-04-24 18:41:39 +02:00
Stefan Agner a19aebed16 Keep add-on update entity in progress across post-install refresh (#168756)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 18:09:25 +02:00
mnaggatz c9d8257465 Return None for Velux cover position when unknown (#168566) 2026-04-24 17:42:30 +02:00
shbatm 5ee6a2181f Fix Flume sensor units and device classes (#169013) 2026-04-24 10:27:37 -05:00
Simone Chemelli ec18e0c6d4 Add uptime device class to the sensor platform (#164266)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 16:18:17 +01:00
Paulus Schoutsen c4426b9476 Add radio_frequency platform to ESPHome (#168448)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 10:48:06 -04:00
Paulus Schoutsen c4fd458d03 Add Honeywell String Lights integration (#168450) 2026-04-24 10:43:56 -04:00
Robert Resch dd71d6cd50 Validate local_only user for signed requests (#169066) 2026-04-24 16:27:15 +02:00
Erik Montnemery 7d494f687e Adjust compound conditions (#169054)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-24 16:21:13 +02:00
Paulus Schoutsen 45adc3d477 Bump rf-protocols to 2.1.0 (#169062) 2026-04-24 09:53:41 -04:00
Tomer 59766bb249 Victron GX: quality scale adjustments (#168988)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 15:45:31 +02:00
Martin d849b12bc7 Add distance device class to Ecowitt lightning distance sensors (#168995) 2026-04-24 15:41:51 +02:00
Manu 8cd2d397d1 Add data descriptions to config flow in OTP integration (#168989) 2026-04-24 15:41:18 +02:00
Jan Bouwhuis 8580a6436d Add MQTT date platform (#168998) 2026-04-24 15:36:21 +02:00
A. Gideonse 7b3b1e34fa Bump indevolt-api to 1.4.2 (#169061) 2026-04-24 15:24:45 +02:00
Maciej Bieniek bb9520856f Bump aiotractive to 1.0.3 (#169059) 2026-04-24 15:14:17 +02:00
Paulus Schoutsen 032dce20b1 Bump aioesphomeapi to 44.21.0 (#169056) 2026-04-24 08:14:12 -05:00
Erik Montnemery a92277b7fa Add method Script.unload (#169036) 2026-04-24 15:12:17 +02:00
Willem-Jan van Rootselaar 10d78d280a Add multiple heating system circuit support to BSBlan (#165992)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 15:12:09 +02:00
Renat Sibgatulin cf1faf3a20 Refactor AirQ config flow tests (#169053) 2026-04-24 15:11:29 +02:00
Ariel Ebersberger ccd82e6b8b Disable sonos tests broken by Python 3.14.3 asyncio changes (#169046)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 14:59:58 +02:00
Erik Montnemery db01b8e421 Migrate async_conditions_from_config to ConditionChecker (#169033) 2026-04-24 14:28:10 +02:00
A. Gideonse bf36c3d193 Bump indevolt-api to 1.4.0 (#169050) 2026-04-24 13:20:19 +02:00
Paulus Schoutsen dd2a90a31f Add radio_frequency entity integration (#168447)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2026-04-24 06:37:28 -04:00
Mattie eb42804871 Add binary sensor platform to Qube heat pump (#166611)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-24 12:30:49 +02:00
Erik Montnemery 6b5bbede52 Update websocket_api.handle_test_condition to use modern condition API (#169029) 2026-04-24 12:25:39 +02:00
Marc Mueller 28c3ca37b9 Refactor pylint plugins to use match statements (#168894) 2026-04-24 12:25:16 +02:00
Franck Nijhof 76376d6b26 Add pylint plugin to detect IP-based unique IDs in config entries (#168822)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: frenck <195327+frenck@users.noreply.github.com>
2026-04-24 10:59:59 +02:00
Raphael Hehl dbb750a583 Add AV1 support for HLS fallback (#161492)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-24 10:07:26 +02:00
Trendafil Gechev aec8d00c95 Add WLED segment freeze support (#168424) 2026-04-24 10:06:49 +02:00
A. Gideonse 39fbd2ccbd Bump indevolt-api to 1.3.1 (#168986) 2026-04-24 09:58:59 +02:00
Denis Shulyaka 1942f12e55 Refactor Anthropic model args (#169014) 2026-04-24 09:58:16 +02:00
Manu eb825796f9 Remove name from config flow of Notifications for Android TV /Fire TV (#169024) 2026-04-24 09:47:03 +02:00
Maciej Bieniek ac6e425748 Add tilt and rotation binary sensors for Shelly Cury (#169002)
Co-authored-by: Copilot <copilot@github.com>
2026-04-24 09:45:13 +02:00
Øyvind Matheson Wergeland cf092c63c0 Declare PARALLEL_UPDATES = 0 for nobo_hub platforms (#169011) 2026-04-24 09:44:54 +02:00
Daniel Hjelseth Høyer 4d8f3dfaf7 Update Tibber library, 0.37.2 (#169027) 2026-04-24 09:44:43 +02:00
Erik Montnemery ed7f2b1810 Migrate compound conditions to ConditionChecker (#169028) 2026-04-24 09:44:28 +02:00
Raphael Hehl 3ff2b4424f Bump uiprotect to 10.3.1 (#169031) 2026-04-24 09:44:08 +02:00
Franck Nijhof f8e6137d28 Update fumis to v0.3.0 (#168984) 2026-04-24 09:24:05 +02:00
Abílio Costa 6a57382eff Allow extracting non-primary entities in websocket command (#168860)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-24 09:13:51 +02:00
Erik Montnemery cebe4aa685 Refactor condition API (#168815)
Co-authored-by: Artur Pragacz <artur@pragacz.com>
2026-04-24 07:46:34 +02:00
Ronald van der Meer 32b9a21294 Bump python-duco-client to 0.3.6 (#169020) 2026-04-24 05:18:55 +02:00
Tomer 7de684d47b Victron GX: Add reconfiguration flow (#168997)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 23:35:30 +02:00
Samuel Xiao 5a9bb972d0 Add sensor description for Lock state in Switchbot Cloud (#168607)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-23 23:34:47 +02:00
Øyvind Matheson Wergeland e1a73fbeed Add select platform tests for nobo_hub (#168738)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:21:26 +02:00
Tom Matheussen 20a88eb21e Add entity availability to Satel Integra (#168476)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 23:20:00 +02:00
Raphael Hehl 0bb678cacf Bump uiprotect to 10.3.0 (#168992)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-23 22:18:18 +01:00
Harvey 0e817c5c90 Bump HueBLE to 2.2.2 (#167677) 2026-04-23 22:48:44 +02:00
fender4645 e5cd1e2830 Tessie: log warning instead of raising UpdateFailed for missing energy history (#168068)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 22:47:15 +02:00
kostavelikov b4c8452a5a Add open (unlatch) support to Homee locks (#168532) 2026-04-23 22:32:51 +02:00
Tomer 86ffb9eccb Victron GX: Add exception translations (#168762)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:30:47 +02:00
epenet 7bf3e75bc8 Fix invalid notification/event handling in Tuya tests (#168854)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 22:14:20 +02:00
Tomer 5394c764b4 Victron GX: Add strict typing (#168907)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 22:11:33 +02:00
Tomer 1cd34e8477 Victron GX stale devices (#168706)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-23 22:06:13 +02:00
Willem-Jan van Rootselaar 0122b2811a Bump python-bsblan to 5.2.0 (#168892) 2026-04-23 22:05:58 +02:00
Artur Pragacz 3f2bc45686 Migrate to entity services in monoprice (#168972) 2026-04-23 22:05:20 +02:00
Franck Nijhof 4612a72cd2 Add reconfiguration flow to Fumis integration (#168759)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 21:53:40 +02:00
Michael 8448ace289 Migrate shopping_list to use entry.runtime_data (#168911) 2026-04-23 21:30:11 +02:00
Ariel Ebersberger 19fd6e2036 Fix b&o race conditions for Python 3.14.3 (#168885) 2026-04-23 21:26:54 +02:00
c0ffeeca7 94ca503f71 Media browser: apply sentence-style capitalization (#168971) 2026-04-23 21:24:25 +02:00
Artur Pragacz fbf30e64a0 Migrate to entity services in amcrest (#168974) 2026-04-23 21:23:43 +02:00
Jan Bouwhuis 49022b69b0 Add MQTT time platform (#168898) 2026-04-23 21:12:28 +02:00
Mick Vleeshouwer 13105bd0b7 Migrate cover platform to entity descriptions in Overkiz (#141330)
Co-authored-by: Copilot <copilot@github.com>
2026-04-23 19:58:17 +02:00
epenet 438c1e9c3d Remove leftover hass.data[DOMAIN] usage from insteon (#168880)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:22:44 +02:00
Christophe Gagnier b0ecc2f36a Add reconfigure flow to TechnoVE integration (#168466)
Co-authored-by: Moustachauve <2206577+Moustachauve@users.noreply.github.com>
2026-04-23 15:09:35 +02:00
Ronald van der Meer 19f19e00f6 Add UCRH sensor support and warn on unknown node types in Duco (#168758) 2026-04-23 15:02:36 +02:00
Raphael Hehl 95ec39ac1a unifi_access: add direction attribute to access events (#168853)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: home-assistant[bot] <78085893+home-assistant[bot]@users.noreply.github.com>
2026-04-23 14:44:57 +02:00
Matthias Alphart c6b4594e7a Update knx-frontend to 2026.4.22.141111 (#168837) 2026-04-23 14:44:24 +02:00
Joost Lekkerkerker cf0b5c6e51 Migrate GitHub to subentries (#160564)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-04-23 14:23:47 +02:00
epenet 187fcd10b3 Add async_panel_exists helper to frontend and use it across integrations (#168884) 2026-04-23 14:14:45 +03:00
TheJulianJES ed1cba02ae Migrate Matter integration to use runtime_data (#168862) 2026-04-23 13:03:08 +02:00
epenet b213eb23c8 Reduce context switching in Tuya initialisation (#168830)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 12:48:15 +02:00
Franck Nijhof 1fec38ef28 2026.4.3 (#168451) 2026-04-17 22:08:45 +02:00
Franck Nijhof 9c4b6951ef Ran hassfest manually 2026-04-17 19:45:38 +00:00
Franck Nijhof 7d2f303035 Bump version to 2026.4.3 2026-04-17 19:35:02 +00:00
Åke Strandberg c61c09fba3 Add cleaning codes for MIele steam oven combo (#168418) 2026-04-17 19:33:51 +00:00
Tom Matheussen 83c807d01c Update satel-integra to 1.2.1 (#168416) 2026-04-17 19:33:50 +00:00
Jan Bouwhuis 1b0386ddfc Fix disabled discovered MQTT entities cleaned up (#168382) 2026-04-17 19:33:48 +00:00
Jamin 0af6a85049 Fix VOIP blocking call in event loop (#168331) 2026-04-17 19:27:38 +00:00
Maciej Bieniek 7bd0bc9c8a Bump imgw-pib to 2.1.0 (#168319) 2026-04-17 19:27:36 +00:00
Christopher Fenner b200930fd4 Bump PyViCare to v2.59.0 (#168254) 2026-04-17 19:27:35 +00:00
johanzander 5c046a3750 Fix unit of measurement for SPH power sensors in growatt_server (#168251)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:27:34 +00:00
renovate[bot] c1a013d718 Update uv to 0.11.6 (#168237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 19:26:34 +00:00
renovate[bot] f1f6cdae2a Update Pillow to 12.2.0 (#168234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-17 19:25:04 +00:00
Daniel Hjelseth Høyer f98de4618a Bump pyTibber to 0.37.1 (#168208) 2026-04-17 19:25:02 +00:00
Daniel Hjelseth Høyer 6b2033b060 Handle Tibber async_get_client failing (#168207)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-04-17 19:25:01 +00:00
Arie Catsman d9dc2bbae4 Bump pyenphase from 2.4.6 to 2.4.8 (#168190) 2026-04-17 19:24:59 +00:00
Marcel van der Veldt 82a1884085 Fix Wyoming satellite memory leak on disconnect (#168152) 2026-04-17 19:24:58 +00:00
Nathan Spencer c46f6721bc Bump pylitterbot to 2025.3.2 (#168146) 2026-04-17 19:24:57 +00:00
Simone Chemelli ed3ff38d30 Bump aioamazondevices to 13.4.1 (#168121) 2026-04-17 19:24:55 +00:00
Simone Chemelli fd3e12a85f Bump aioamazondevices to 13.4.0 (#167984) 2026-04-17 19:24:54 +00:00
Retha Runolfsson 1e0a0b70f4 Fix Switchbot Keypad Vision doorbell detection (#168098)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-04-17 19:21:51 +00:00
AlCalzone 2598dde7aa Update Z-Wave cover moving state based on current position and cover capabilities (#168096) 2026-04-17 19:21:49 +00:00
Tom Matheussen 9af7fe22bd Bump satel-integra to 1.1.1 (#168091) 2026-04-17 19:21:48 +00:00
Tom Matheussen 546eef2eee Bump satel_integra to 1.1.0 (#167353) 2026-04-17 19:21:46 +00:00
Retha Runolfsson d65b7ce2f3 Bump PySwitchbot to 2.0.1 (#168090) 2026-04-17 17:29:23 +00:00
gerculanum b09671a409 Fix missing kWh unit for dlq ADD_ELE energy sensor (#168026)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-17 17:29:22 +00:00
J. Nick Koston 412771465d Fix ESPHome cold/warm white color temperature read-back (#167972) 2026-04-17 17:29:21 +00:00
Michael 3a72bc23b9 Don't create cpu temperature sensor when not supported in FRITZ!Box Tools (#167905) 2026-04-17 17:29:20 +00:00
Colin b3d7ba5ce5 Fix openevse charging_current and charging_power units (#167863) 2026-04-17 17:29:18 +00:00
Aidan Timson 4e7b6838eb Fix device_class removal in template binary sensors (#167775) 2026-04-17 17:29:17 +00:00
Kevin O'Brien 437f5ef66c Fix Proxmox VE storage usage percentage crash on missing used_fraction (#167136)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:29:15 +00:00
Franck Nijhof f886b03e14 2026.4.2 (#167939) 2026-04-11 20:41:23 +02:00
Willem-Jan van Rootselaar 190ee49e3a Bump python-bsblan to version 5.1.4 (#167987) 2026-04-11 18:00:37 +00:00
Erwin Douna f7c5a51f46 Portainer fix fetching swarm stacks (#167979) 2026-04-11 17:56:13 +00:00
tronikos e4e9c22016 Bump opower to 0.18.1 (#167967) 2026-04-11 17:56:12 +00:00
Norbert Rittel f2df848e3f Fix spelling of "Shut down" button label in proxmoxve (#167059) 2026-04-11 17:56:10 +00:00
Franck Nijhof cdce98faaf Update cryptography to 46.0.7 (#167960) 2026-04-11 08:12:40 +00:00
Martin Hjelmare fde103cdfd Fix tibber price sensor first state update (#167938)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-11 08:02:37 +00:00
Daniel Hjelseth Høyer fcd6c6e335 Improve Tibber price coordinator (#166175)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-11 08:02:36 +00:00
Franck Nijhof 8f2cec26e3 Merge branch 'master' into rc 2026-04-10 22:45:55 +02:00
Franck Nijhof 05463cde99 Bump version to 2026.4.2 2026-04-10 20:41:40 +00:00
On Freund a948799a6e Bump pyrisco to 0.6.8 (#167924) 2026-04-10 20:41:20 +00:00
Bram Kragten 624fab064a Update frontend to 20260325.7 (#167922) 2026-04-10 20:41:19 +00:00
Nathan Spencer a331cb7199 Bump pylitterbot to 2025.2.1 (#167921) 2026-04-10 20:41:17 +00:00
Thomas D 7d6eaf40a6 Fix light on action for qbus integration (#167917) 2026-04-10 20:41:16 +00:00
panosmz 1ae9e7c87d Bump oasatelematics to 0.4 (#167911) 2026-04-10 20:41:14 +00:00
Thomas D 6bcfc32d48 Bump qbusmqttapi to 1.4.3 (#167909) 2026-04-10 20:41:13 +00:00
Joost Lekkerkerker 0b5f85bdb9 Bump zinvolt to 0.4.3 (#167908) 2026-04-10 20:41:12 +00:00
Maikel Punie d153eee822 Bump velbusaio to 2026.4.0 (#167868) 2026-04-10 20:41:10 +00:00
TheJulianJES afcc2113ce Bump ZHA to 1.1.2 (#167849) 2026-04-10 20:40:12 +00:00
J. Diego Rodríguez Royo ae5bd63993 Fix service.yaml values for Home Connect (#167847) 2026-04-10 20:38:38 +00:00
Simone Chemelli 78107c478d Fix stale devices removal for Alexa devices (#167837) 2026-04-10 20:38:36 +00:00
Joost Lekkerkerker 84490ef0bb Support Chess.com accounts with no name (#167824) 2026-04-10 20:38:34 +00:00
Raj Laud 887e14638b Fix Victron BLE storage errors caused by non-serializable value_fn callable in sensor entity description (#167819)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:38:32 +00:00
Raj Laud 818bde1d5e Fix Victron BLE false reauth triggered by unknown enum bitmask combinations (#167809)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:38:31 +00:00
Benjamin Hudgens 83da18b761 Revert "Fix Ring snapshots" - #164337 (#167790) 2026-04-10 20:38:29 +00:00
Maciej Bieniek bd904caea1 Bump aiotractive to 1.0.2 (#167783) 2026-04-10 20:38:28 +00:00
Michael 500f030eaa Set proper state for the internet_access switches in FRITZ!Box Tools (#167767) 2026-04-10 20:38:26 +00:00
wollew ce755f5f8f Bump pyvlx to 0.2.33 (#167764) 2026-04-10 20:38:25 +00:00
Nick Haghiri fb766d164b Improve error logging for Backblaze B2 upload failures (#167721) 2026-04-10 20:38:23 +00:00
Tom 394670e33f Fix ProxmoxVE migration causing reauthentication (#167624) 2026-04-10 20:38:21 +00:00
G Johansson f79285f9ab Bump holidays to 0.94 (#167604) 2026-04-10 20:38:20 +00:00
Erik Montnemery a422611ada Fix securetar size calculation when encrypting backup (#167602) 2026-04-10 20:38:18 +00:00
Erik Montnemery 4c34dcd560 Bump securetar to 2026.4.0 (#167600) 2026-04-10 20:38:17 +00:00
Maciej Bieniek 1aca993c12 Fix Tractive switch availability (#167599) 2026-04-10 20:38:15 +00:00
Leo Periou a8cc099b66 fix EWS deviceType problem (#167597) 2026-04-10 20:38:13 +00:00
Artur Pragacz c56d67c02f Set up condition and trigger helpers in check config script (#167589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 20:38:11 +00:00
Jan Čermák 0ce98cfb34 Remove homeassistant/actions/helpers/info from builder workflow (#167573) 2026-04-10 20:35:03 +00:00
Jan Bouwhuis 4a13ab9aff Bump incomfort-client to v0.7.0 (#167546) 2026-04-10 20:28:23 +00:00
Fabian Neundorf dc65646d8b Bump python-picnic-api2 to 1.3.4 (#167539)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-04-10 20:28:22 +00:00
Nelson Osacky 39fbdad775 Add missing Miele dishwasher program ID 201 (#167536)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:28:20 +00:00
Jordan Harvey b4f6a43a14 Bump pynintendoparental to 2.3.4 (#167510) 2026-04-10 20:28:19 +00:00
Nick Haghiri e5ff7a9944 Handle BadRequest exception in Backblaze B2 config flow and setup (#167482) 2026-04-10 20:28:18 +00:00
Nick Haghiri ca9945f750 Bump b2sdk to 2.10.4 (#167481) 2026-04-10 20:28:16 +00:00
Andrea Turri b028e2a6ae Miele - fix core temperature reading (#167476) 2026-04-10 20:28:15 +00:00
Allen Porter 6f4aca495b Update roborock services to raise ServiceNotSupported for new devices that don't yet support it (#167470)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 20:28:14 +00:00
Jamie Magee a892b5364d Fix nzbget positional argument mismatch in NZBGetAPI calls (#167456) 2026-04-10 20:28:12 +00:00
Steve Easley f57e682a98 Bump jvcprojector dependency to pyjvcprojector 2.0.5 (#167450) 2026-04-10 20:28:11 +00:00
Nils Ove Erstad 3493517b6d Fix missing color_mode initialization in MQTT JSON light schema (#167429) 2026-04-10 20:28:09 +00:00
Jordan Harvey b5842b8484 Fix handling of missing period statistics in Anglian Water coordinator (#167427)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 20:28:08 +00:00
Willem-Jan van Rootselaar 3333b8d019 Fix setup without dhw (#167423) 2026-04-10 20:28:07 +00:00
Simone Chemelli 745860553c Bump aiocomelit to 2.0.2 (#167414) 2026-04-10 20:26:02 +00:00
Shai Ungar 7188a09a59 Use dedicated session for seventeentrack to preserve login cookies (#167394)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:26:01 +00:00
Robert Svensson 96a9b89412 Bump axis to v68 to improve MQTT event resiliance (#167373) 2026-04-10 20:25:59 +00:00
Tom 586d7ab526 Improve ProxmoxVE permissions handling (#167370) 2026-04-10 20:25:58 +00:00
Joost Lekkerkerker 5f2fe4ffd4 Bump aiohue to 4.8.1 (#167369) 2026-04-10 20:25:56 +00:00
Simone Chemelli 040192c103 Align and cleanup tests data for Fritz (#167363) 2026-04-10 20:25:55 +00:00
Jordan Harvey e85430105e Bump cryptography to 46.0.6 (#167330)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-04-10 20:22:31 +00:00
cdheiser 5c7c0a6e83 Bump pylutron to 0.4.1 (#167324) 2026-04-10 20:21:53 +00:00
007hacky007 c7bd673d01 Bump afsapi to 0.3.1 (#167321) 2026-04-10 20:21:51 +00:00
MarkGodwin 6d3a93df81 Update to tplink-omada-client 1.5.7 (#167313) 2026-04-10 20:21:50 +00:00
Raj Laud 6a934b5fe3 Fix victron ble reauth flow title (#167307)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 20:21:48 +00:00
Daniel Hjelseth Høyer d644348dc8 Bump pyTibber to 0.37.0 (#167283) 2026-04-10 20:21:47 +00:00
Ludovic BOUÉ dbfde9266c Add Hisense AC (0x138C/0x0101) to Matter dry and fan mode device lists (#167282)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-10 20:21:45 +00:00
Simone Chemelli ed0b68ec4a Allow force alarm actions for Comelit (#167202) 2026-04-10 20:21:44 +00:00
Patrick c32d523f63 Bump starlink-grpc-core to 1.2.5 (#167195) 2026-04-10 20:21:42 +00:00
Marco Sousa 98a4e27e35 Bump aiopvpc to 4.3.1 (#167189) 2026-04-10 20:21:41 +00:00
Alex Merkel fb1365e9a4 [LG Soundbar] Fix incorrect state for some models (#167094) 2026-04-10 20:21:39 +00:00
Willem-Jan van Rootselaar 850b034a5f Include port in BSB-LAN configuration URL when non-default (#166480) 2026-04-10 20:19:09 +00:00
Samuel Xiao b880876e0e Switchbot Cloud: Enable Webhook for Bot (#165647) 2026-04-10 20:13:56 +00:00
Jeef ab601e5717 Prevent the intellifire client from polling independently of its coordinator (#165341)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 20:13:54 +00:00
Matt Philips 7eda592c72 Improve handling of disconnected meters with Rainforest Automation Eagle-200 (#161185)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-10 20:13:53 +00:00
Franck Nijhof b981ece163 Pin actions/helpers/info to fix release build (#167327) 2026-04-03 20:58:54 +00:00
Franck Nijhof 7ea931fdc8 2026.4.1 (#167310) 2026-04-03 22:10:54 +02:00
Franck Nijhof f3038a20af Bump version to 2026.4.1 2026-04-03 16:05:08 +00:00
Pete Sage de234c7190 Sonos alarm switch entities may not be created when speaker offline initially (#167303) 2026-04-03 16:01:17 +00:00
Pete Sage 399681984f Bump soco to 0.30.15 (#167299) 2026-04-03 16:01:16 +00:00
Joost Lekkerkerker 5ca14ca7d7 Bump Zinvolt to 0.4.1 (#167296) 2026-04-03 16:01:15 +00:00
Joost Lekkerkerker ac53cfa85a Make sure we take all Zinvolt battery units in account (#167294) 2026-04-03 16:01:13 +00:00
Ludovic BOUÉ 02f1a9c3a9 Fix Matter water heater off mode (#167286)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-03 16:01:12 +00:00
Bram Kragten f93fdceac9 Update frontend to 20260325.6 (#167285) 2026-04-03 16:01:11 +00:00
Ludovic BOUÉ 711a89f7b8 Fix to allow Matter Fan percent setting to be null when FanMode is Auto (#167279)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-03 16:01:09 +00:00
Norbert Rittel 19e58c554e Improve Assist satellite action naming consistency (#167278) 2026-04-03 16:01:08 +00:00
Joost Lekkerkerker feb6c2bfe6 Bump zinvolt to 0.4.0 (#167276) 2026-04-03 16:01:07 +00:00
Norbert Rittel 6bb91422ff Improve Media player action naming consistency (#167274)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-03 16:01:06 +00:00
Andrew Jackson 3bd699285b Remove Transmission port forward sensor (#167269) 2026-04-03 16:00:17 +00:00
dotlambda 6d10305197 Bump psutil to 7.2.2 (#167263) 2026-04-03 15:57:57 +00:00
Joakim Plate 42a9c8488d Update arcam to 1.8.3 (#167249) 2026-04-03 15:57:56 +00:00
Norbert Rittel c6c273559e Improve Recorder action naming consistency (#167244) 2026-04-03 15:57:55 +00:00
Pete Sage f7394ce302 Fix Sonos reporting wrong state when media title is whitespace (#167223) 2026-04-03 15:57:53 +00:00
G Johansson 175dec6f1a Bump holiday library to 0.93 (#167217) 2026-04-03 15:57:52 +00:00
G Johansson d137761cb5 Fix SMHI (#167212) 2026-04-03 15:57:50 +00:00
Simone Chemelli 8055cbc58d Migrate image unique_id for Fritz (#167209) 2026-04-03 15:57:49 +00:00
Joost Lekkerkerker c9dff27590 Remove not implemented supported feature from Wiim (#167205) 2026-04-03 15:57:48 +00:00
Mike Degatano c913a858b6 Wrap hassio import in is_hassio check in get_system_info helper (#167111) 2026-04-03 15:57:46 +00:00
Joost Lekkerkerker 4ed33a804e Bump pySmartThings to 3.7.3 (#167075) 2026-04-03 15:57:45 +00:00
Kevin O'Brien 8bf5674826 Fix Proxmox VE backup status sensor false positive due to case mismatch (#167069)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 15:57:43 +00:00
Manu b8a0b0083b Fix websocket calling async_release_notes in update component although unavailable (#167067) 2026-04-03 15:57:42 +00:00
Bram Kragten a57c101b5e Fix select condition state selector (#167064) 2026-04-03 15:57:41 +00:00
Brett Adams 957b8c1c52 Fix Tesla Fleet OAuth scope refresh during reauth (#166920) 2026-04-03 15:57:40 +00:00
Brett Adams bb002d051b Fix Tesla Fleet charge current scope handling (#166919) 2026-04-03 15:57:38 +00:00
LTek 2b2fd4ac92 Fix Ring snapshots (#164337)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-04-03 15:57:37 +00:00
Jan Bouwhuis f4c270629b Fix tuya energy sensor units (#160392) 2026-04-03 15:57:35 +00:00
885 changed files with 39548 additions and 9298 deletions
+1
View File
@@ -36,6 +36,7 @@ base_platforms: &base_platforms
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/radio_frequency/**
- homeassistant/components/light/**
- homeassistant/components/lock/**
- homeassistant/components/media_player/**
+2 -7
View File
@@ -5,7 +5,7 @@
# Copilot code review instructions
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not add comments about code style, formatting or linting issues.
- Do not comment on code style, formatting or linting issues.
# GitHub Copilot & Claude Code Instructions
@@ -21,7 +21,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Python Syntax Notes
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14.
## Testing
@@ -34,8 +34,3 @@ Integrations with Platinum or Gold level in the Integration Quality Scale reflec
When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
# Skills
- ha-integration-knowledge: .claude/skills/ha-integration-knowledge/SKILL.md
@@ -0,0 +1,47 @@
---
applyTo: "homeassistant/components/**, tests/components/**"
excludeAgent: "cloud-agent"
---
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
## File Locations
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## General guidelines
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
- "potato" is a forbidden word for an integration and should never be used.
The following platforms have extra guidelines:
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
## Integration Quality Scale
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `exempt`: Rule doesn't apply (with reason in comment)
- `todo`: Rule needs implementation
## Testing Requirements
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
+1 -1
View File
@@ -14,7 +14,7 @@ env:
UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0"
BASE_IMAGE_VERSION: "2026.02.0"
ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
+1 -1
View File
@@ -1 +1 @@
3.14.2
3.14.3
+1
View File
@@ -599,6 +599,7 @@ homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.victron_gx.*
homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.*
+1 -1
View File
@@ -12,7 +12,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
## Python Syntax Notes
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14.
## Testing
Generated
+8 -2
View File
@@ -758,6 +758,8 @@ CLAUDE.md @home-assistant/core
/tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/honeywell_string_lights/ @balloob
/tests/components/honeywell_string_lights/ @balloob
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
@@ -1201,6 +1203,8 @@ CLAUDE.md @home-assistant/core
/tests/components/notify_events/ @matrozov @papajojo
/homeassistant/components/notion/ @bachya
/tests/components/notion/ @bachya
/homeassistant/components/novy_cooker_hood/ @piitaya
/tests/components/novy_cooker_hood/ @piitaya
/homeassistant/components/nrgkick/ @andijakl
/tests/components/nrgkick/ @andijakl
/homeassistant/components/nsw_fuel_station/ @nickw444
@@ -1415,6 +1419,8 @@ CLAUDE.md @home-assistant/core
/tests/components/radarr/ @tkdrob
/homeassistant/components/radio_browser/ @frenck
/tests/components/radio_browser/ @frenck
/homeassistant/components/radio_frequency/ @home-assistant/core
/tests/components/radio_frequency/ @home-assistant/core
/homeassistant/components/radiotherm/ @vinnyfuria
/tests/components/radiotherm/ @vinnyfuria
/homeassistant/components/rainbird/ @konikvranik @allenporter
@@ -1983,8 +1989,8 @@ CLAUDE.md @home-assistant/core
/tests/components/wled/ @frenck @mik-laj
/homeassistant/components/wmspro/ @mback2k
/tests/components/wmspro/ @mback2k
/homeassistant/components/wolflink/ @adamkrol93 @mtielen
/tests/components/wolflink/ @adamkrol93 @mtielen
/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM
/tests/components/wolflink/ @adamkrol93 @EnjoyingM
/homeassistant/components/workday/ @fabaff @gjohansson-ST
/tests/components/workday/ @fabaff @gjohansson-ST
/homeassistant/components/worldclock/ @fabaff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"domain": "honeywell",
"name": "Honeywell",
"integrations": ["lyric", "evohome", "honeywell"]
"integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"]
}
@@ -4,7 +4,7 @@ from __future__ import annotations
from asyncio import timeout
from collections.abc import Mapping
from typing import Any
from typing import TYPE_CHECKING, Any
from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError
from aiohttp import ClientError
@@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientConnectorError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -55,8 +55,11 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
if TYPE_CHECKING:
assert accuweather.location_name is not None
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
title=accuweather.location_name, data=user_input
)
return self.async_show_form(
@@ -70,9 +73,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(
CONF_NAME, default=self.hass.config.location_name
): str,
}
),
errors=errors,
@@ -64,7 +64,7 @@ class AccuWeatherObservationDataUpdateCoordinator(
"""Initialize."""
self.accuweather = accuweather
self.location_key = accuweather.location_key
name = config_entry.data[CONF_NAME]
name = config_entry.data.get(CONF_NAME) or config_entry.title
if TYPE_CHECKING:
assert self.location_key is not None
@@ -122,7 +122,7 @@ class AccuWeatherForecastDataUpdateCoordinator(
self.accuweather = accuweather
self.location_key = accuweather.location_key
self._fetch_method = fetch_method
name = config_entry.data[CONF_NAME]
name = config_entry.data.get(CONF_NAME) or config_entry.title
if TYPE_CHECKING:
assert self.location_key is not None
@@ -25,8 +25,7 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
"longitude": "[%key:common::config_flow::data::longitude%]"
},
"data_description": {
"api_key": "API key generated in the AccuWeather APIs portal."
+24 -1
View File
@@ -38,6 +38,7 @@ HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
"HEAT": HVACMode.HEAT,
"FAN": HVACMode.FAN_ONLY,
"AUTO": HVACMode.AUTO,
"DRY": HVACMode.DRY,
"OFF": HVACMode.OFF,
}
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
@@ -79,7 +80,6 @@ class ActronAirClimateEntity(ClimateEntity):
)
_attr_name = None
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@@ -93,6 +93,17 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
super().__init__(coordinator)
self._attr_unique_id = self._serial_number
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of supported HVAC modes."""
modes = [
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
for mode in self._status.user_aircon_settings.supported_modes
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
]
modes.append(HVACMode.OFF)
return modes
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
@@ -179,6 +190,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
super().__init__(coordinator, zone)
self._attr_unique_id: str = self._zone_identifier
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of supported HVAC modes."""
status = self.coordinator.data
modes = [
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
for mode in status.user_aircon_settings.supported_modes
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
]
modes.append(HVACMode.OFF)
return modes
@property
def min_temp(self) -> float:
"""Return the minimum temperature that can be set."""
@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["actron-neo-api==0.5.3"]
"requirements": ["actron-neo-api==0.5.6"]
}
@@ -25,7 +25,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource(
hass,
DOMAIN,
"AI Generated Images",
"AI generated images",
{IMAGE_DIR: str(media_dir)},
f"/{DOMAIN}",
)
+9 -12
View File
@@ -12,11 +12,11 @@ from airly.exceptions import AirlyError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS
from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS
DESCRIPTION_PLACEHOLDERS = {
"developer_registration_url": "https://developer.airly.eu/register",
@@ -45,16 +45,16 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
try:
location_point_valid = await check_location(
websession,
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
user_input[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
)
if not location_point_valid:
location_nearest_valid = await check_location(
websession,
user_input["api_key"],
user_input["latitude"],
user_input["longitude"],
user_input[CONF_API_KEY],
user_input[CONF_LATITUDE],
user_input[CONF_LONGITUDE],
use_nearest=True,
)
except AirlyError as err:
@@ -68,7 +68,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="wrong_location")
use_nearest = True
return self.async_create_entry(
title=user_input[CONF_NAME],
title=DEFAULT_NAME,
data={**user_input, CONF_USE_NEAREST: use_nearest},
)
@@ -83,9 +83,6 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_LONGITUDE, default=self.hass.config.longitude
): cv.longitude,
vol.Optional(
CONF_NAME, default=self.hass.config.location_name
): str,
}
),
errors=errors,
+2
View File
@@ -37,3 +37,5 @@ MAX_UPDATE_INTERVAL: Final = 90
MIN_UPDATE_INTERVAL: Final = 5
NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet."
URL = "https://airly.org/map/#{latitude},{longitude}"
DEFAULT_NAME: Final = "Airly"
+2 -2
View File
@@ -127,7 +127,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
),
AirlySensorEntityDescription(
key=ATTR_API_CO,
translation_key="co",
device_class=SensorDeviceClass.CO,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
@@ -178,7 +178,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airly sensor entities based on a config entry."""
name = entry.data[CONF_NAME]
name = entry.data.get(CONF_NAME) or entry.title
coordinator = entry.runtime_data
+1 -5
View File
@@ -13,8 +13,7 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"name": "[%key:common::config_flow::data::name%]"
"longitude": "[%key:common::config_flow::data::longitude%]"
},
"description": "To generate API key go to {developer_registration_url}"
}
@@ -24,9 +23,6 @@
"sensor": {
"caqi": {
"name": "Common air quality index"
},
"co": {
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
}
}
},
@@ -36,6 +36,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
# pylint: disable-next=hass-unique-id-ip-based
await self.async_set_unique_id(user_input[CONF_HOST])
self._abort_if_unique_id_configured()
return self.async_create_entry(
+1 -2
View File
@@ -39,7 +39,6 @@ from homeassistant.helpers.typing import ConfigType
from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors
from .camera import STREAM_SOURCE_LIST
from .const import (
CAMERAS,
COMM_RETRIES,
COMM_TIMEOUT,
DATA_AMCREST,
@@ -359,7 +358,7 @@ def _start_event_monitor(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amcrest IP Camera component."""
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}})
for device in config[DOMAIN]:
name: str = device[CONF_NAME]
+10 -74
View File
@@ -12,13 +12,11 @@ import aiohttp
from aiohttp import web
from amcrest import AmcrestError
from haffmpeg.camera import CameraMjpeg
import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import (
async_aiohttp_proxy_stream,
async_aiohttp_proxy_web,
@@ -29,11 +27,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
ATTR_COLOR_BW,
CAMERA_WEB_SESSION_TIMEOUT,
CAMERAS,
CBW,
COMM_TIMEOUT,
DATA_AMCREST,
DEVICES,
MOV,
RESOLUTION_TO_STREAM,
SERVICE_UPDATE,
SNAPSHOT_TIMEOUT,
@@ -49,65 +49,11 @@ SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"]
_ATTR_PTZ_TT = "travel_time"
_ATTR_PTZ_MOV = "movement"
_MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
_ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"]
_MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"]
_MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"]
_ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS
_DEFAULT_TT = 0.2
_ATTR_PRESET = "preset"
_ATTR_COLOR_BW = "color_bw"
_CBW_COLOR = "color"
_CBW_AUTO = "auto"
_CBW_BW = "bw"
_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids})
_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend(
{vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}
)
_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)})
_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend(
{
vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
}
)
CAMERA_SERVICES = {
"enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()),
"disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()),
"enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()),
"disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()),
"enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()),
"disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()),
"goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)),
"set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)),
"start_tour": (_SRV_SCHEMA, "async_start_tour", ()),
"stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()),
"ptz_control": (
_SRV_PTZ_SCHEMA,
"async_ptz_control",
(_ATTR_PTZ_MOV, _ATTR_PTZ_TT),
),
}
_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
@@ -275,7 +221,7 @@ class AmcrestCam(Camera):
self._motion_recording_enabled
)
if self._color_bw is not None:
attr[_ATTR_COLOR_BW] = self._color_bw
attr[ATTR_COLOR_BW] = self._color_bw
return attr
@property
@@ -322,15 +268,7 @@ class AmcrestCam(Camera):
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self) -> None:
"""Subscribe to signals and add camera to list."""
self._unsub_dispatcher.extend(
async_dispatcher_connect(
self.hass,
service_signal(service, self.entity_id),
getattr(self, callback_name),
)
for service, (_, callback_name, _) in CAMERA_SERVICES.items()
)
"""Subscribe to signals."""
self._unsub_dispatcher.append(
async_dispatcher_connect(
self.hass,
@@ -338,11 +276,9 @@ class AmcrestCam(Camera):
self.async_on_demand_update,
)
)
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Remove camera from list and disconnect from signals."""
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
"""Disconnect from signals."""
for unsub_dispatcher in self._unsub_dispatcher:
unsub_dispatcher()
@@ -456,7 +392,7 @@ class AmcrestCam(Camera):
async def async_ptz_control(self, movement: str, travel_time: float) -> None:
"""Move or zoom camera in specified direction."""
code = _ACTION[_MOV.index(movement)]
code = _ACTION[MOV.index(movement)]
kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0}
if code in _MOVE_1_ACTIONS:
@@ -613,10 +549,10 @@ class AmcrestCam(Camera):
)
async def _async_get_color_mode(self) -> str:
return _CBW[await self._api.async_day_night_color]
return CBW[await self._api.async_day_night_color]
async def _async_set_color_mode(self, cbw: str) -> None:
await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0)
await self._api.async_set_day_night_color(CBW.index(cbw), channel=0)
async def _async_set_color_bw(self, cbw: str) -> None:
"""Set camera color mode."""
+15 -1
View File
@@ -2,7 +2,6 @@
DOMAIN = "amcrest"
DATA_AMCREST = DOMAIN
CAMERAS = "cameras"
DEVICES = "devices"
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
@@ -17,3 +16,18 @@ SERVICE_UPDATE = "update"
RESOLUTION_LIST = {"high": 0, "low": 1}
RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"}
ATTR_COLOR_BW = "color_bw"
CBW = ["color", "auto", "bw"]
MOV = [
"zoom_out",
"zoom_in",
"right",
"left",
"up",
"down",
"right_down",
"right_up",
"left_down",
"left_up",
]
+57 -52
View File
@@ -1,62 +1,67 @@
"""Support for Amcrest IP cameras."""
"""Services for Amcrest IP cameras."""
from __future__ import annotations
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.service import async_extract_entity_ids
import voluptuous as vol
from .camera import CAMERA_SERVICES
from .const import CAMERAS, DATA_AMCREST, DOMAIN
from .helpers import service_signal
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV
_ATTR_PRESET = "preset"
_ATTR_PTZ_MOV = "movement"
_ATTR_PTZ_TT = "travel_time"
_DEFAULT_TT = 0.2
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the Amcrest IP Camera services."""
for service_name, func in (
("enable_recording", "async_enable_recording"),
("disable_recording", "async_disable_recording"),
("enable_audio", "async_enable_audio"),
("disable_audio", "async_disable_audio"),
("enable_motion_recording", "async_enable_motion_recording"),
("disable_motion_recording", "async_disable_motion_recording"),
("start_tour", "async_start_tour"),
("stop_tour", "async_stop_tour"),
):
service.async_register_platform_entity_service(
hass,
DOMAIN,
service_name,
entity_domain=CAMERA_DOMAIN,
schema=None,
func=func,
)
def have_permission(user: User | None, entity_id: str) -> bool:
return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL)
async def async_extract_from_service(call: ServiceCall) -> list[str]:
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(context=call.context)
else:
user = None
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id
for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE:
return []
call_ids = await async_extract_entity_ids(call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
raise Unauthorized(
context=call.context, entity_id=entity_id, permission=POLICY_CONTROL
)
entity_ids.append(entity_id)
return entity_ids
async def async_service_handler(call: ServiceCall) -> None:
args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]]
for entity_id in await async_extract_from_service(call):
async_dispatcher_send(hass, service_signal(call.service, entity_id), *args)
for service, params in CAMERA_SERVICES.items():
hass.services.async_register(DOMAIN, service, async_service_handler, params[0])
service.async_register_platform_entity_service(
hass,
DOMAIN,
"goto_preset",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))},
func="async_goto_preset",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_color_bw",
entity_domain=CAMERA_DOMAIN,
schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)},
func="async_set_color_bw",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
"ptz_control",
entity_domain=CAMERA_DOMAIN,
schema={
vol.Required(_ATTR_PTZ_MOV): vol.In(MOV),
vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float,
},
func="async_ptz_control",
)
+411 -228
View File
@@ -1,7 +1,8 @@
"""Base entity for Anthropic."""
import base64
from collections.abc import AsyncGenerator, Callable, Iterable
from collections import deque
from collections.abc import AsyncIterator, Callable, Iterable
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
@@ -20,18 +21,22 @@ from anthropic.types import (
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockContent,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlock,
ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta,
JSONOutputFormatParam,
Message,
MessageDeltaUsage,
MessageParam,
MessageStreamEvent,
ModelInfo,
OutputConfigParam,
RawContentBlockDelta,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
@@ -68,18 +73,30 @@ from anthropic.types import (
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockContent,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block import (
Content as BashCodeExecutionToolResultBlockContent,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.raw_message_delta_event import Delta
from anthropic.types.text_editor_code_execution_tool_result_block import (
Content as TextEditorCodeExecutionToolResultBlockContent,
)
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
)
from anthropic.types.tool_search_tool_result_block import (
Content as ToolSearchToolResultBlockContent,
)
from anthropic.types.tool_search_tool_result_block_param import (
Content as ToolSearchToolResultBlockParamContentParam,
)
from anthropic.types.tool_use_block import Caller
import voluptuous as vol
from voluptuous_openapi import convert
@@ -91,7 +108,7 @@ from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from homeassistant.util.json import JsonObjectType
from homeassistant.util.json import JsonArrayType, JsonObjectType
from .const import (
CONF_CHAT_MODEL,
@@ -445,13 +462,7 @@ def _convert_content( # noqa: C901
return messages, container_id
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
class AnthropicDeltaStream:
"""Transform the response stream into HA format.
A typical stream of responses might look something like the following:
@@ -481,201 +492,376 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
Each message could contain multiple blocks of the same type.
"""
if stream is None or not hasattr(stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
def __init__(
self,
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> None:
"""Initialize the delta stream."""
self._chat_log: conversation.ChatLog = chat_log
self._stream: AsyncStream[MessageStreamEvent] = stream
self._output_tool: str | None = output_tool
self._buffer: deque[
conversation.AssistantContentDeltaDict
| conversation.ToolResultContentDeltaDict
] = deque()
self._stream_iterator: AsyncIterator[MessageStreamEvent] | None = None
self._current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = (
None
)
self._current_tool_args: str = ""
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._input_usage: Usage | None = None
self._first_block: bool = True
current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None
current_tool_args: str
content_details = ContentDetails()
content_details.add_citation_detail()
input_usage: Usage | None = None
first_block: bool = True
def __aiter__(
self,
) -> AsyncIterator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
"""Initialize the stream and return the async iterator."""
if self._stream is None or not hasattr(self._stream, "__aiter__"):
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="unexpected_stream_object"
)
if self._stream_iterator is None:
self._stream_iterator = self._stream.__aiter__()
return self
async for response in stream:
LOGGER.debug("Received response: %s", response)
async def __anext__(
self,
) -> (
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
):
"""Get the next item from the stream."""
while True:
if self._buffer:
return self._buffer.popleft()
if isinstance(response, RawMessageStartEvent):
input_usage = response.message.usage
first_block = True
elif isinstance(response, RawContentBlockStartEvent):
if isinstance(response.content_block, ToolUseBlock):
current_tool_block = ToolUseBlockParam(
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
)
current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block
or (
not content_details.has_citations()
and response.content_block.citations is None
and content_details.has_content()
)
):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
yield {"role": "assistant"}
first_block = False
content_details.add_citation_detail()
if response.content_block.text:
content_details.citation_details[-1].length += len(
response.content_block.text
)
yield {"content": response.content_block.text}
elif isinstance(response.content_block, ThinkingBlock):
if first_block or content_details.thinking_signature:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
elif isinstance(response.content_block, RedactedThinkingBlock):
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if first_block or content_details.redacted_thinking:
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
first_block = False
content_details.redacted_thinking = response.content_block.data
elif isinstance(response.content_block, ServerToolUseBlock):
current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {
"role": "tool_result",
"tool_call_id": response.content_block.tool_use_id,
"tool_name": response.content_block.type.removesuffix(
"_tool_result"
),
"tool_result": {
"content": cast(
JsonObjectType, response.content_block.to_dict()["content"]
)
}
if isinstance(response.content_block.content, list)
else cast(JsonObjectType, response.content_block.content.to_dict()),
response = await self._stream_iterator.__anext__() # type: ignore[union-attr]
LOGGER.debug("Received response: %s", response)
self.on_message_stream_event(response)
def on_message_stream_event(self, event: MessageStreamEvent) -> None:
"""Handle MessageStreamEvent."""
if isinstance(event, RawMessageStartEvent):
self.on_message_start_event(event.message)
return
if isinstance(event, RawContentBlockStartEvent):
self.on_content_block_start_event(event.content_block, event.index)
return
if isinstance(event, RawContentBlockDeltaEvent):
self.on_content_block_delta_event(event.delta)
return
if isinstance(event, RawContentBlockStopEvent):
self.on_content_block_stop_event(event.index)
return
if isinstance(event, RawMessageDeltaEvent):
self.on_message_delta_event(event.delta, event.usage)
return
if isinstance(event, RawMessageStopEvent):
self.on_message_stop_event()
return
LOGGER.debug("Unhandled event type: %s", event.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
def on_message_start_event(self, message: Message) -> None:
"""Handle RawMessageStartEvent."""
self._input_usage = message.usage
self._first_block = True
def on_content_block_start_event(
self, content_block: ContentBlock, index: int
) -> None:
"""Handle RawContentBlockStartEvent."""
if isinstance(content_block, ToolUseBlock):
self.on_tool_use_block(
content_block.id,
content_block.input,
content_block.name,
content_block.caller,
)
return
if isinstance(content_block, TextBlock):
self.on_text_block(content_block.text, content_block.citations)
return
if isinstance(content_block, ThinkingBlock):
self.on_thinking_block(content_block.thinking, content_block.signature)
return
if isinstance(content_block, RedactedThinkingBlock):
self.on_redacted_thinking_block(content_block.data)
return
if isinstance(content_block, ServerToolUseBlock):
self.on_server_tool_use_block(
content_block.id,
content_block.name,
content_block.input,
content_block.caller,
)
return
if isinstance(
content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
ToolSearchToolResultBlock,
),
):
self.on_server_tool_result_block(
content_block.tool_use_id,
content_block.type,
content_block.content,
content_block.caller if hasattr(content_block, "caller") else None,
)
return
LOGGER.debug("Unhandled content block type: %s", content_block.type)
def on_tool_use_block(
self, id: str, input: dict[str, Any], name: str, caller: Caller | None
) -> None:
"""Handle ToolUseBlock."""
self._current_tool_block = ToolUseBlockParam(
type="tool_use",
id=id,
name=name,
input=input,
)
self._current_tool_args = ""
if name == self._output_tool:
if self._first_block or self._content_details.has_content():
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
"""Handle TextBlock."""
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
self._first_block
or (
not self._content_details.has_citations()
and citations is None
and self._content_details.has_content()
)
):
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._buffer.append({"role": "assistant"})
self._first_block = False
self._content_details.add_citation_detail()
if text:
self._content_details.citation_details[-1].length += len(text)
self._buffer.append({"content": text})
def on_thinking_block(self, thinking: str, signature: str) -> None:
"""Handle ThinkingBlock."""
if self._first_block or self._content_details.thinking_signature:
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
def on_redacted_thinking_block(self, data: str) -> None:
"""Handle RedactedThinkingBlock."""
LOGGER.debug(
"Some of Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt affect the quality of "
"responses"
)
if self._first_block or self._content_details.redacted_thinking:
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append({"role": "assistant"})
self._first_block = False
self._content_details.redacted_thinking = data
def on_server_tool_use_block(
self,
id: str,
name: Literal[
"web_search",
"web_fetch",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
"tool_search_tool_regex",
"tool_search_tool_bm25",
],
input: dict[str, Any],
caller: Caller | None,
) -> None:
"""Handle ServerToolUseBlock."""
self._current_tool_block = ServerToolUseBlockParam(
type="server_tool_use",
id=id,
name=name,
input=input,
)
self._current_tool_args = ""
def on_server_tool_result_block(
self,
tool_use_id: str,
tool_name: Literal[
"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
| CodeExecutionToolResultBlockContent
| BashCodeExecutionToolResultBlockContent
| TextEditorCodeExecutionToolResultBlockContent
| ToolSearchToolResultBlockContent,
caller: Caller | None,
) -> None:
"""Handle various server tool result blocks."""
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
self._buffer.append(
{
"role": "tool_result",
"tool_call_id": tool_use_id,
"tool_name": tool_name.removesuffix("_tool_result"),
"tool_result": {
"content": cast(JsonArrayType, [x.to_dict() for x in content])
}
first_block = True
elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta):
if (
current_tool_block is not None
and current_tool_block["name"] == output_tool
):
content_details.citation_details[-1].length += len(
response.delta.partial_json
)
yield {"content": response.delta.partial_json}
else:
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
if response.delta.text:
content_details.citation_details[-1].length += len(
response.delta.text
)
yield {"content": response.delta.text}
elif isinstance(response.delta, ThinkingDelta):
if response.delta.thinking:
yield {"thinking_content": response.delta.thinking}
elif isinstance(response.delta, SignatureDelta):
content_details.thinking_signature = response.delta.signature
elif isinstance(response.delta, CitationsDelta):
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None:
if current_tool_block["name"] == output_tool:
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] |= tool_args
yield {
if isinstance(content, list)
else cast(JsonObjectType, content.to_dict()),
}
)
self._first_block = True
def on_content_block_delta_event(self, delta: RawContentBlockDelta) -> None:
"""Handle RawContentBlockDeltaEvent."""
if isinstance(delta, InputJSONDelta):
self.on_input_json_delta(delta.partial_json)
return
if isinstance(delta, TextDelta):
self.on_text_delta(delta.text)
return
if isinstance(delta, ThinkingDelta):
self.on_thinking_delta(delta.thinking)
return
if isinstance(delta, SignatureDelta):
self.on_signature_delta(delta.signature)
return
if isinstance(delta, CitationsDelta):
self.on_citations_delta(delta.citation)
return
LOGGER.debug("Unhandled content delta type: %s", delta.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that
def on_input_json_delta(self, partial_json: str) -> None:
"""Handle InputJSONDelta."""
if (
self._current_tool_block is not None
and self._current_tool_block["name"] == self._output_tool
):
self._content_details.citation_details[-1].length += len(partial_json)
self._buffer.append({"content": partial_json})
else:
self._current_tool_args += partial_json
def on_text_delta(self, text: str) -> None:
"""Handle TextDelta."""
if text:
self._content_details.citation_details[-1].length += len(text)
self._buffer.append({"content": text})
def on_thinking_delta(self, thinking: str) -> None:
"""Handle ThinkingDelta."""
if thinking:
self._buffer.append({"thinking_content": thinking})
def on_signature_delta(self, signature: str) -> None:
"""Handle SignatureDelta."""
self._content_details.thinking_signature = signature
def on_citations_delta(self, citation: TextCitation) -> None:
"""Handle CitationsDelta."""
self._content_details.add_citation(citation)
def on_content_block_stop_event(self, index: int) -> None:
"""Handle RawContentBlockStopEvent."""
if self._current_tool_block is not None:
if self._current_tool_block["name"] == self._output_tool:
self._current_tool_block = None
return
tool_args = (
json.loads(self._current_tool_args) if self._current_tool_args else {}
)
self._current_tool_block["input"] |= tool_args
self._buffer.append(
{
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=current_tool_block["input"],
external=current_tool_block["type"] == "server_tool_use",
id=self._current_tool_block["id"],
tool_name=self._current_tool_block["name"],
tool_args=self._current_tool_block["input"],
external=self._current_tool_block["type"]
== "server_tool_use",
)
]
}
current_tool_block = None
elif isinstance(response, RawMessageDeltaEvent):
if (usage := response.usage) is not None:
chat_log.async_trace(_create_token_stats(input_usage, usage))
content_details.container = response.delta.container
if response.delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
elif isinstance(response, RawMessageStopEvent):
if content_details:
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
)
self._current_tool_block = None
def on_message_delta_event(self, delta: Delta, usage: MessageDeltaUsage) -> None:
"""Handle RawMessageDeltaEvent."""
self._chat_log.async_trace(self._create_token_stats(self._input_usage, usage))
self._content_details.container = delta.container
if delta.stop_reason == "refusal":
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="api_refusal"
)
def _create_token_stats(
input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
def on_message_stop_event(self) -> None:
"""Handle RawMessageStopEvent."""
if self._content_details:
self._content_details.delete_empty()
self._buffer.append({"native": self._content_details})
self._content_details = ContentDetails()
self._content_details.add_citation_detail()
def _create_token_stats(
self, input_usage: Usage | None, response_usage: MessageDeltaUsage
) -> dict[str, Any]:
"""Create token stats for conversation agent tracing."""
input_tokens = 0
cached_input_tokens = 0
if input_usage:
input_tokens = input_usage.input_tokens
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
output_tokens = response_usage.output_tokens
return {
"stats": {
"input_tokens": input_tokens,
"cached_input_tokens": cached_input_tokens,
"output_tokens": output_tokens,
}
}
}
class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
@@ -703,15 +889,14 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
entry_type=dr.DeviceEntryType.SERVICE,
)
async def _async_handle_chat_log( # noqa: C901
async def _get_model_args( # noqa: C901
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
) -> tuple[MessageCreateParamsStreaming, str | None]:
"""Get the model arguments."""
options: dict[str, Any] = DEFAULT | self.subentry.data
preloaded_tools = [
"HassTurnOn",
@@ -729,21 +914,18 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
messages, container_id = _convert_content(chat_log.content[1:])
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
model = options[CONF_CHAT_MODEL]
model_args = MessageCreateParamsStreaming(
model=model,
messages=messages,
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
max_tokens=options[CONF_MAX_TOKENS],
system=system.content,
stream=True,
container=container_id,
)
if (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.PROMPT
):
if options[CONF_PROMPT_CACHING] == PromptCaching.PROMPT:
model_args["system"] = [
{
"type": "text",
@@ -751,19 +933,14 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
"cache_control": {"type": "ephemeral"},
}
]
elif (
options.get(CONF_PROMPT_CACHING, DEFAULT[CONF_PROMPT_CACHING])
== PromptCaching.AUTOMATIC
):
elif options[CONF_PROMPT_CACHING] == PromptCaching.AUTOMATIC:
model_args["cache_control"] = {"type": "ephemeral"}
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.adaptive.supported
):
thinking_effort = options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
thinking_effort = options[CONF_THINKING_EFFORT]
if thinking_effort != "none":
model_args["thinking"] = ThinkingConfigAdaptiveParam(
type="adaptive", display="summarized"
@@ -772,9 +949,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
else:
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
else:
thinking_budget = options.get(
CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET]
)
thinking_budget = options[CONF_THINKING_BUDGET]
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.types.enabled.supported
@@ -791,9 +966,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
and self.model_info.capabilities.effort.supported
):
model_args["output_config"] = OutputConfigParam(
effort=options.get(
CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT]
)
effort=options[CONF_THINKING_EFFORT]
)
tools: list[ToolUnionParam] = []
@@ -803,12 +976,12 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
for tool in chat_log.llm_api.tools
]
if options.get(CONF_CODE_EXECUTION):
if options[CONF_CODE_EXECUTION]:
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_WEB_SEARCH)
or not options[CONF_WEB_SEARCH]
):
tools.append(
CodeExecutionTool20250825Param(
@@ -817,26 +990,26 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
),
)
if options.get(CONF_WEB_SEARCH):
if options[CONF_WEB_SEARCH]:
if (
not self.model_info.capabilities
or not self.model_info.capabilities.code_execution.supported
or not options.get(CONF_CODE_EXECUTION)
or not options[CONF_CODE_EXECUTION]
):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
max_uses=options[CONF_WEB_SEARCH_MAX_USES],
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
if options[CONF_WEB_SEARCH_USER_LOCATION]:
web_search["user_location"] = {
"type": "approximate",
"city": options.get(CONF_WEB_SEARCH_CITY, ""),
@@ -937,10 +1110,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
preloaded_tools.append(structure_name)
if tools:
if (
options.get(CONF_TOOL_SEARCH, DEFAULT[CONF_TOOL_SEARCH])
and len(tools) > len(preloaded_tools) + 1
):
if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1:
for tool in tools:
if not tool["name"].endswith(tuple(preloaded_tools)):
tool["defer_loading"] = True
@@ -953,6 +1123,19 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
model_args["tools"] = tools
return model_args, structure_name
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
max_iterations: int = MAX_TOOL_ITERATIONS,
) -> None:
"""Generate an answer for the chat log."""
model_args, structure_name = await self._get_model_args(
chat_log, structure_name, structure
)
coordinator = self.entry.runtime_data
client = coordinator.client
@@ -966,7 +1149,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(
AnthropicDeltaStream(
chat_log,
stream,
output_tool=structure_name or None,
@@ -974,7 +1157,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
)
]
)
messages.extend(new_messages)
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
except anthropic.AuthenticationError as err:
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
await coordinator.async_request_refresh()
@@ -155,7 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass.data[DATA_COMPONENT] = storage_collection
collection.DictStorageCollectionWebsocket(
storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
storage_collection,
DOMAIN,
DOMAIN,
CREATE_FIELDS,
UPDATE_FIELDS,
admin_only=True,
).async_setup(hass)
websocket_api.async_register_command(hass, handle_integration_list)
@@ -341,6 +346,7 @@ async def handle_integration_list(
vol.Required("config_entry_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def handle_config_entry(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+1 -1
View File
@@ -28,7 +28,7 @@ class AquacellEntity(CoordinatorEntity[AquacellCoordinator]):
self._attr_unique_id = f"{softener_key}-{entity_key}"
self._attr_device_info = DeviceInfo(
name=self.softener.name,
hw_version=self.softener.fwVersion,
hw_version=self.softener.diagnostics.fw_version,
identifiers={(DOMAIN, str(softener_key))},
manufacturer=self.softener.brand,
model=self.softener.ssn,
@@ -8,5 +8,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["aioaquacell"],
"requirements": ["aioaquacell==0.2.0"]
"requirements": ["aioaquacell==1.0.0"]
}
+7 -7
View File
@@ -38,39 +38,39 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
translation_key="salt_left_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.leftPercent,
value_fn=lambda softener: softener.salt.left_percent,
),
SoftenerSensorEntityDescription(
key="salt_right_side_percentage",
translation_key="salt_right_side_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.salt.rightPercent,
value_fn=lambda softener: softener.salt.right_percent,
),
SoftenerSensorEntityDescription(
key="salt_left_side_time_remaining",
translation_key="salt_left_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.leftDays,
value_fn=lambda softener: softener.salt.left_days,
),
SoftenerSensorEntityDescription(
key="salt_right_side_time_remaining",
translation_key="salt_right_side_time_remaining",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
value_fn=lambda softener: softener.salt.rightDays,
value_fn=lambda softener: softener.salt.right_days,
),
SoftenerSensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda softener: softener.battery,
value_fn=lambda softener: softener.diagnostics.battery,
),
SoftenerSensorEntityDescription(
key="wi_fi_strength",
translation_key="wi_fi_strength",
value_fn=lambda softener: softener.wifiLevel,
value_fn=lambda softener: softener.diagnostics.wifi_level,
device_class=SensorDeviceClass.ENUM,
options=[
"high",
@@ -82,7 +82,7 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
key="last_update",
translation_key="last_update",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda softener: softener.lastUpdate,
value_fn=lambda softener: softener.diagnostics.last_update,
),
)
+29 -18
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace, IntOrTypeEnum
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
from homeassistant.components.sensor import (
@@ -21,6 +22,25 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArcamFmjConfigEntry
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
def _enum_options(value: type[IntOrTypeEnum]) -> list[str]:
return [
member.name.lower() for member in value if not member.name.startswith("CODE_")
]
def _enum_value(value: IntOrTypeEnum | None) -> str | None:
if value is None:
return None
if value.name.startswith("CODE_"):
_LOGGER.debug("Undefined enum value %s ignored", value)
return None
return value.name.lower()
@dataclass(frozen=True, kw_only=True)
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
@@ -75,9 +95,9 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_video_aspect_ratio",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingVideoAspectRatio],
options=_enum_options(IncomingVideoAspectRatio),
value_fn=lambda state: (
vp.aspect_ratio.name.lower()
_enum_value(vp.aspect_ratio)
if (vp := state.get_incoming_video_parameters()) is not None
else None
),
@@ -87,11 +107,10 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_video_colorspace",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingVideoColorspace],
options=_enum_options(IncomingVideoColorspace),
value_fn=lambda state: (
vp.colorspace.name.lower()
_enum_value(vp.colorspace)
if (vp := state.get_incoming_video_parameters()) is not None
and vp.colorspace is not None
else None
),
),
@@ -100,24 +119,16 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
translation_key="incoming_audio_format",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingAudioFormat],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[0]) is not None
else None
),
options=_enum_options(IncomingAudioFormat),
value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[0]),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_config",
translation_key="incoming_audio_config",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=[member.name.lower() for member in IncomingAudioConfig],
value_fn=lambda state: (
result.name.lower()
if (result := state.get_incoming_audio_format()[1]) is not None
else None
),
options=_enum_options(IncomingAudioConfig),
value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[1]),
),
ArcamFmjSensorEntityDescription(
key="incoming_audio_sample_rate",
@@ -13,11 +13,12 @@ from hassil.util import (
)
import voluptuous as vol
from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
@@ -103,6 +104,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_ask_question(call: ServiceCall) -> dict[str, Any]:
"""Handle a Show View service call."""
satellite_entity_id: str = call.data[ATTR_ENTITY_ID]
if call.context.user_id:
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(
context=call.context,
permission=POLICY_CONTROL,
user_id=call.context.user_id,
)
if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL):
raise Unauthorized(
context=call.context,
permission=POLICY_CONTROL,
user_id=call.context.user_id,
perm_category=CAT_ENTITIES,
)
satellite_entity: AssistSatelliteEntity | None = component.get_entity(
satellite_entity_id
)
@@ -165,6 +165,7 @@ async def websocket_set_wake_words(
vol.Required("entity_id"): cv.entity_domain(DOMAIN),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_test_connection(
hass: HomeAssistant,
+18 -25
View File
@@ -15,24 +15,6 @@ from homeassistant.data_entry_flow import FlowContext
from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey
WS_TYPE_SETUP_MFA = "auth/setup_mfa"
SCHEMA_WS_SETUP_MFA = vol.All(
websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{
vol.Required("type"): WS_TYPE_SETUP_MFA,
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
vol.Exclusive("flow_id", "module_or_flow_id"): str,
vol.Optional("user_input"): object,
}
),
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
)
WS_TYPE_DEPOSE_MFA = "auth/depose_mfa"
SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str}
)
DATA_SETUP_FLOW_MGR: HassKey[MfaFlowManager] = HassKey("auth_mfa_setup_flow_manager")
_LOGGER = logging.getLogger(__name__)
@@ -73,16 +55,24 @@ def async_setup(hass: HomeAssistant) -> None:
"""Init mfa setup flow manager."""
hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass)
websocket_api.async_register_command(
hass, WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA
)
websocket_api.async_register_command(
hass, WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA
)
websocket_api.async_register_command(hass, websocket_setup_mfa)
websocket_api.async_register_command(hass, websocket_depose_mfa)
@callback
@websocket_api.websocket_command(
vol.All(
vol.Schema(
{
vol.Required("type"): "auth/setup_mfa",
vol.Exclusive("mfa_module_id", "module_or_flow_id"): str,
vol.Exclusive("flow_id", "module_or_flow_id"): str,
vol.Optional("user_input"): object,
}
),
cv.has_at_least_one_key("mfa_module_id", "flow_id"),
)
)
@websocket_api.ws_require_user(allow_system_user=False)
def websocket_setup_mfa(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
@@ -121,6 +111,9 @@ def websocket_setup_mfa(
@callback
@websocket_api.websocket_command(
{vol.Required("type"): "auth/depose_mfa", vol.Required("mfa_module_id"): str}
)
@websocket_api.ws_require_user(allow_system_user=False)
def websocket_depose_mfa(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
@@ -4,10 +4,10 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
from collections.abc import Callable, Mapping
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, Protocol, cast
from typing import Any, cast
from propcache.api import cached_property
import voluptuous as vol
@@ -229,14 +229,11 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool
)
class IfAction(Protocol):
class IfAction(condition_helper.ConditionsChecker):
"""Define the format of if_action."""
config: list[ConfigType]
def __call__(self, variables: Mapping[str, Any] | None = None) -> bool:
"""AND all conditions."""
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
"""Return true if specified automation entity_id is on.
@@ -835,7 +832,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if (
not skip_condition
and self._condition is not None
and not self._condition(variables)
and not self._condition.async_check(variables=variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
@@ -904,6 +901,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
async def _async_enable_automation(self, event: Event) -> None:
"""Start automation on startup."""
@@ -1276,6 +1276,7 @@ async def _async_process_if(
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
@websocket_api.require_admin
def websocket_config(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
+1
View File
@@ -36,6 +36,7 @@ async def get_axis_api(
username=config[CONF_USERNAME],
password=config[CONF_PASSWORD],
web_proto=config.get(CONF_PROTOCOL, "http"),
websocket_enabled=True,
)
)
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==68"],
"requirements": ["axis==69"],
"ssdp": [
{
"manufacturer": "AXIS"
+6 -3
View File
@@ -2,6 +2,7 @@
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.service import async_register_admin_service
from .const import DATA_MANAGER, DOMAIN
@@ -30,7 +31,9 @@ async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
def async_setup_services(hass: HomeAssistant) -> None:
"""Register services."""
if not is_hassio(hass):
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
hass.services.async_register(
DOMAIN, "create_automatic", _async_handle_create_automatic_service
async_register_admin_service(
hass, DOMAIN, "create", _async_handle_create_service
)
async_register_admin_service(
hass, DOMAIN, "create_automatic", _async_handle_create_automatic_service
)
@@ -21,8 +21,9 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
from .const import DOMAIN, MANUFACTURER, BeoModel
from .services import async_setup_services
from .util import get_remotes
from .websocket import BeoWebsocket
@@ -58,15 +59,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
# Remove casts to str
assert entry.unique_id
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
name=entry.title,
model=entry.data[CONF_MODEL],
)
client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context())
# Check API and WebSocket connection
@@ -83,6 +75,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:
await client.close_api_client()
raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error
# Create device now as BeoWebsocket needs a device for debug logging, firing events etc.
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, entry.unique_id)},
model=entry.data[CONF_MODEL],
)
# Create devices for paired Beoremote One remotes
for remote in await get_remotes(client):
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, entry.unique_id),
)
websocket = BeoWebsocket(hass, entry, client)
# Add the websocket and API client
@@ -52,6 +52,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
_beolink_jid = ""
_client: MozartClient
_friendly_name = ""
_host = ""
_model = ""
_name = ""
@@ -111,6 +112,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
)
self._beolink_jid = beolink_self.jid
self._friendly_name = beolink_self.friendly_name
self._serial_number = get_serial_number_from_jid(beolink_self.jid)
await self.async_set_unique_id(self._serial_number)
@@ -149,6 +151,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="invalid_address")
self._model = discovery_info.hostname[:-16].replace("-", " ")
self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME]
self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER]
self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com"
@@ -164,16 +167,13 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN):
async def _create_entry(self) -> ConfigFlowResult:
"""Create the config entry for a discovered or manually configured Bang & Olufsen device."""
# Ensure that created entities have a unique and easily identifiable id and not a "friendly name"
self._name = f"{self._model}-{self._serial_number}"
return self.async_create_entry(
title=self._name,
title=self._friendly_name,
data=EntryData(
host=self._host,
jid=self._beolink_jid,
model=self._model,
name=self._name,
name=self._friendly_name,
),
)
@@ -20,7 +20,6 @@ from .const import (
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
MANUFACTURER,
BeoModel,
WebsocketNotification,
)
@@ -142,12 +141,6 @@ class BeoRemoteKeyEvent(BeoEvent):
self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}",
model=BeoModel.BEOREMOTE_ONE,
serial_number=remote.serial_number,
sw_version=remote.app_version,
manufacturer=MANUFACTURER,
via_device=(DOMAIN, self._unique_id),
)
# Make the native key name Home Assistant compatible
@@ -115,7 +115,7 @@ class BeoSensorRemoteBatteryLevel(BeoSensor):
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")},
)
self._attr_native_value = remote.battery_level
self._remote = remote
+14 -6
View File
@@ -32,19 +32,27 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = {
}
TRIGGERS: dict[str, type[Trigger]] = {
"low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON),
"not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF),
"low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
),
"not_low": make_entity_target_state_trigger(
BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
),
"started_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False
),
"stopped_charging": make_entity_target_state_trigger(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False
),
"level_changed": make_entity_numerical_state_changed_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
),
"level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%"
BATTERY_PERCENTAGE_DOMAIN_SPECS,
valid_unit="%",
primary_entities_only=False,
),
}
@@ -33,16 +33,19 @@
entity:
- domain: binary_sensor
device_class: battery
primary_entities_only: false
.trigger_target_charging: &trigger_target_charging
entity:
- domain: binary_sensor
device_class: battery_charging
primary_entities_only: false
.trigger_target_percentage: &trigger_target_percentage
entity:
- domain: sensor
device_class: battery
primary_entities_only: false
low:
fields:
@@ -21,9 +21,6 @@
"save_video": {
"service": "mdi:file-video"
},
"send_pin": {
"service": "mdi:two-factor-authentication"
},
"trigger_camera": {
"service": "mdi:image-refresh"
}
+3 -49
View File
@@ -5,15 +5,9 @@ from __future__ import annotations
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.const import (
ATTR_CONFIG_ENTRY_ID,
CONF_FILE_PATH,
CONF_FILENAME,
CONF_PIN,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, service
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
@@ -23,50 +17,10 @@ SERVICE_SAVE_VIDEO = "save_video"
SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips"
# Deprecated
SERVICE_SEND_PIN = "send_pin"
SERVICE_SEND_PIN_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_PIN): cv.string,
}
)
async def _send_pin(call: ServiceCall) -> None:
"""Call blink to send new pin."""
# Create repair issue to inform user about service removal
ir.async_create_issue(
call.hass,
DOMAIN,
"service_send_pin_deprecation",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
breaks_in_ha_version="2026.5.0",
translation_key="service_send_pin_deprecation",
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
)
# Service has been removed - raise exception
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="service_removed",
translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"},
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Blink integration."""
hass.services.async_register(
DOMAIN,
SERVICE_SEND_PIN,
_send_pin,
schema=SERVICE_SEND_PIN_SCHEMA,
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
@@ -35,15 +35,3 @@ save_recent_clips:
example: "/tmp"
selector:
text:
send_pin:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: blink
pin:
example: "abc123"
selector:
text:
@@ -82,9 +82,6 @@
},
"not_loaded": {
"message": "{target} is not loaded."
},
"service_removed": {
"message": "The service {service_name} has been removed and is no longer needed. Home Assistant will automatically prompt for reauthentication when required."
}
},
"issues": {
@@ -98,10 +95,6 @@
}
},
"title": "Blink update service is being removed"
},
"service_send_pin_deprecation": {
"description": "The service {service_name} has been removed and is no longer needed. When a new two-factor authentication code is required, Home Assistant will automatically prompt you to reauthenticate through the integration configuration. Please remove any automations or scripts that call this service.",
"title": "Blink send PIN service has been removed"
}
},
"options": {
@@ -140,20 +133,6 @@
},
"name": "Save video"
},
"send_pin": {
"description": "Sends a new PIN to Blink for 2FA.",
"fields": {
"config_entry_id": {
"description": "The Blink integration ID.",
"name": "Integration ID"
},
"pin": {
"description": "PIN received from Blink. Leave empty if you only received a verification email.",
"name": "PIN"
}
},
"name": "Send PIN"
},
"trigger_camera": {
"description": "Requests camera to take new image.",
"name": "Trigger camera"
@@ -7,6 +7,7 @@ DOMAIN = "broadlink"
DOMAINS_AND_TYPES = {
Platform.CLIMATE: {"HYS"},
Platform.LIGHT: {"LB1", "LB2"},
Platform.RADIO_FREQUENCY: {"RM4PRO", "RMPRO"},
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.SELECT: {"HYS"},
Platform.SENSOR: {
@@ -0,0 +1,132 @@
"""Radio Frequency platform for Broadlink."""
from __future__ import annotations
import logging
from broadlink.exceptions import BroadlinkException
from rf_protocols import RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .device import BroadlinkDevice
from .entity import BroadlinkEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
_TICK_US = 32.84
_RF_433_TYPE_BYTE = 0xB2
_RF_315_TYPE_BYTE = 0xB4
_RF_433_RANGE = (433_050_000, 434_790_000)
_RF_315_RANGE = (314_950_000, 315_250_000)
SUPPORTED_FREQUENCY_RANGES: list[tuple[int, int]] = [_RF_433_RANGE, _RF_315_RANGE]
def _type_byte_for_frequency(frequency: int) -> int:
"""Return the Broadlink RF type byte for a given carrier frequency."""
if _RF_433_RANGE[0] <= frequency <= _RF_433_RANGE[1]:
return _RF_433_TYPE_BYTE
if _RF_315_RANGE[0] <= frequency <= _RF_315_RANGE[1]:
return _RF_315_TYPE_BYTE
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="frequency_not_supported",
translation_placeholders={"frequency": f"{frequency / 1_000_000:g}"},
)
def encode_rf_packet(
*,
type_byte: int,
repeat_count: int,
timings_us: list[int],
) -> bytes:
"""Encode raw OOK timings as a Broadlink RF pulse-length packet.
The layout is::
byte 0 type byte (0xB2 for 433 MHz, 0xB4 for 315 MHz)
byte 1 repeat count (additional transmissions after the first)
bytes 2..3 payload length (little-endian), counted from byte 4
bytes 4..N-1 pulses: 1 byte when ticks < 256, otherwise
0x00 followed by a 2-byte big-endian tick count
Each pulse is expressed as multiples of 32.84 µs ticks, which is the
timing resolution of the Broadlink RF front-end.
"""
buf = bytearray([type_byte, repeat_count, 0, 0])
for duration in timings_us:
ticks = round(abs(duration) / _TICK_US)
div, mod = divmod(ticks, 256)
if div:
buf.append(0x00)
buf.append(div)
buf.append(mod)
payload_len = len(buf) - 4
buf[2] = payload_len & 0xFF
buf[3] = (payload_len >> 8) & 0xFF
return bytes(buf)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Broadlink radio frequency transmitter."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
device: BroadlinkDevice = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkRadioFrequency(device)])
class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity):
"""Representation of a Broadlink RF transmitter."""
_attr_has_entity_name = True
_attr_name = None
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
super().__init__(device)
self._attr_unique_id = device.unique_id
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return the Broadlink-supported narrow RF bands."""
return SUPPORTED_FREQUENCY_RANGES
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Encode an OOK command and transmit it via the Broadlink device."""
type_byte = _type_byte_for_frequency(command.frequency)
packet = encode_rf_packet(
type_byte=type_byte,
repeat_count=command.repeat_count,
timings_us=command.get_raw_timings(),
)
_LOGGER.debug(
"Transmitting RF packet: %d bytes on %d Hz (repeat=%d)",
len(packet),
command.frequency,
command.repeat_count,
)
device = self._device
try:
await device.async_request(device.api.send_data, packet)
except (BroadlinkException, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="transmit_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -77,5 +77,13 @@
"name": "Total consumption"
}
}
},
"exceptions": {
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
"transmit_failed": {
"message": "Failed to transmit RF command: {error}"
}
}
}
+131 -15
View File
@@ -13,6 +13,7 @@ from bsblan import (
Info,
StaticState,
)
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -28,11 +29,16 @@ from homeassistant.exceptions import (
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PASSKEY, DOMAIN, LOGGER
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -52,7 +58,35 @@ class BSBLanData:
client: BSBLAN
device: Device
info: Info
static: StaticState | None
static: dict[int, StaticState | None]
available_circuits: list[int]
def get_bsblan_device_info(
device: Device, info: Info, host: str, port: int
) -> DeviceInfo:
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
info.device_identification.value
if info.device_identification and info.device_identification.value
else None
),
model_id=(
f"{info.controller_family.value}_{info.controller_variant.value}"
if info.controller_family
and info.controller_variant
and info.controller_family.value
and info.controller_variant.value
else None
),
sw_version=device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -75,13 +109,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# create BSBLAN client
session = async_get_clientsession(hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
await bsblan.initialize()
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
bsblan.device(),
@@ -110,18 +148,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
translation_key="setup_general_error",
) from err
try:
static = await bsblan.static_values()
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s: %s",
entry.data[CONF_HOST],
err,
)
static = None
# Fetch static values per configured circuit.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
# Static values are optional — some devices may not support them.
static_per_circuit: dict[int, StaticState | None] = {}
for circuit in circuits:
try:
static_per_circuit[circuit] = await bsblan.static_values(circuit=circuit)
except (BSBLANError, TimeoutError) as err:
LOGGER.debug(
"Static values not available for %s circuit %d: %s",
entry.data[CONF_HOST],
circuit,
err,
)
static_per_circuit[circuit] = None
# Create coordinators with the already-initialized client
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan, circuits)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of fast coordinator (required for entities)
@@ -137,7 +182,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
slow_coordinator=slow_coordinator,
device=device,
info=info,
static=static,
static=static_per_circuit,
available_circuits=circuits,
)
# Register main device before forwarding platforms, so sub-devices
# (heating circuits, water heater) can reference it via via_device
device_registry = dr.async_get(hass)
port = entry.data.get(CONF_PORT, DEFAULT_PORT)
main_device_info = get_bsblan_device_info(device, info, entry.data[CONF_HOST], port)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers=main_device_info["identifiers"],
connections=main_device_info["connections"],
name=main_device_info["name"],
manufacturer=main_device_info["manufacturer"],
model=main_device_info.get("model"),
model_id=main_device_info.get("model_id"),
sw_version=main_device_info.get("sw_version"),
configuration_url=main_device_info.get("configuration_url"),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -148,3 +211,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Unload BSBLAN config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool:
"""Migrate old config entries to the latest schema."""
LOGGER.debug(
"Migrating BSB-LAN entry from version %s.%s",
entry.version,
entry.minor_version,
)
if entry.version > 1:
# Downgraded from a future version; cannot migrate.
return False
# 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = [1]
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
port=entry.data[CONF_PORT],
username=entry.data.get(CONF_USERNAME),
password=entry.data.get(CONF_PASSWORD),
)
session = async_get_clientsession(hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
circuits = await bsblan.get_available_circuits()
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to single circuit [1]. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_HEATING_CIRCUITS: circuits},
minor_version=2,
)
LOGGER.debug(
"Migrated BSB-LAN entry to version %s.%s with circuits %s",
entry.version,
entry.minor_version,
circuits,
)
return True
+28 -15
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Final
from bsblan import BSBLANError, get_hvac_action_category
from bsblan import BSBLANError, State, get_hvac_action_category
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
from .entity import BSBLanEntity
from .entity import BSBLanCircuitEntity
PARALLEL_UPDATES = 1
@@ -63,10 +63,12 @@ async def async_setup_entry(
) -> None:
"""Set up BSBLAN device based on a config entry."""
data = entry.runtime_data
async_add_entities([BSBLANClimate(data)])
async_add_entities(
BSBLANClimate(data, circuit) for circuit in data.available_circuits
)
class BSBLANClimate(BSBLanEntity, ClimateEntity):
class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_name = None
@@ -84,37 +86,50 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
def __init__(
self,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLAN climate device."""
super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
super().__init__(data.fast_coordinator, data, circuit)
self._circuit = circuit
mac = format_mac(data.device.MAC)
# Set temperature range if available, otherwise use Home Assistant defaults
if (static := data.static) is not None:
# Backward compatible unique ID: circuit 1 keeps old format
if circuit == 1:
self._attr_unique_id = f"{mac}-climate"
else:
self._attr_unique_id = f"{mac}-climate-{circuit}"
# Set temperature range from per-circuit static data
if (static := data.static.get(circuit)) is not None:
if (min_temp := static.min_temp) is not None and min_temp.value is not None:
self._attr_min_temp = min_temp.value
if (max_temp := static.max_temp) is not None and max_temp.value is not None:
self._attr_max_temp = max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property
def _circuit_state(self) -> State:
"""Return the state for this circuit."""
return self.coordinator.data.states[self._circuit]
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if (current_temp := self.coordinator.data.state.current_temperature) is None:
if (current_temp := self._circuit_state.current_temperature) is None:
return None
return current_temp.value
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if (target_temp := self.coordinator.data.state.target_temperature) is None:
if (target_temp := self._circuit_state.target_temperature) is None:
return None
return target_temp.value
@property
def _hvac_mode_value(self) -> int | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
if (hvac_mode := self._circuit_state.hvac_mode) is None:
return None
return hvac_mode.value
@@ -128,9 +143,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac action."""
if (
action := self.coordinator.data.state.hvac_action
) is None or action.value is None:
if (action := self._circuit_state.hvac_action) is None or action.value is None:
return None
category = get_hvac_action_category(action.value)
return HVACAction(category.name.lower())
@@ -170,7 +183,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
data[ATTR_HVAC_MODE] = 1
try:
await self.coordinator.client.thermostat(**data)
await self.coordinator.client.thermostat(**data, circuit=self._circuit)
except BSBLANError as err:
raise HomeAssistantError(
"An error occurred while updating the BSBLAN device",
+38 -5
View File
@@ -15,19 +15,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = [1]
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -77,7 +79,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# Try to get device info without authentication to minimize discovery popup
config = BSBLANConfig(host=self.host, port=self.port)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
try:
device = await bsblan.device()
except BSBLANError:
@@ -123,6 +125,8 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
)
if not self._auth_required:
# Discover available heating circuits
await self._discover_circuits()
return self._async_create_entry()
self.passkey = user_input.get(CONF_PASSKEY)
@@ -137,6 +141,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Validate device connection and create entry."""
try:
await self._get_bsblan_info()
await self._discover_circuits()
except BSBLANAuthError:
if is_discovery:
return self.async_show_form(
@@ -230,9 +235,12 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
# it gets the unique ID from the device info when it validates credentials
self._abort_if_unique_id_mismatch()
# Rediscover circuits in case hardware changed
await self._discover_circuits()
return self.async_update_reload_and_abort(
existing_entry,
data_updates=user_input,
data_updates={**user_input, CONF_HEATING_CIRCUITS: self.circuits},
reason="reconfigure_successful",
)
@@ -316,13 +324,14 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
return self.async_create_entry(
title=format_mac(self.mac),
title="BSB-LAN",
data={
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_PASSKEY: self.passkey,
CONF_USERNAME: self.username,
CONF_PASSWORD: self.password,
CONF_HEATING_CIRCUITS: self.circuits,
},
)
@@ -340,7 +349,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config, session)
bsblan = BSBLAN(config=config, session=session)
device = await bsblan.device()
retrieved_mac = device.MAC
@@ -362,3 +371,27 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_PORT: self.port,
}
)
async def _discover_circuits(self) -> None:
"""Discover available heating circuits."""
config = BSBLANConfig(
host=self.host,
passkey=self.passkey,
port=self.port,
username=self.username,
password=self.password,
)
session = async_get_clientsession(self.hass)
bsblan = BSBLAN(config=config, session=session)
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
except (
BSBLANError,
TimeoutError,
):
LOGGER.debug(
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = [1]
+1
View File
@@ -22,5 +22,6 @@ ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature"
ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_PORT: Final = 80
+12 -6
View File
@@ -49,7 +49,7 @@ DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
class BSBLanFastData:
"""BSBLan fast-polling data."""
state: State
states: dict[int, State]
sensor: Sensor
dhw: HotWaterState | None = None
@@ -94,6 +94,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
hass: HomeAssistant,
config_entry: BSBLanConfigEntry,
client: BSBLAN,
circuits: list[int],
) -> None:
"""Initialize the BSB-LAN fast coordinator."""
super().__init__(
@@ -103,14 +104,19 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL_FAST,
)
self.circuits: list[int] = circuits
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-LAN device."""
states: dict[int, State] = {}
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
# Use include filtering to only fetch parameters we actually use.
# BSB-LAN is a serial bus — it processes one parameter at a time,
# so concurrent requests offer no speed benefit over sequential.
for circuit in self.circuits:
states[circuit] = await self.client.state(
include=STATE_INCLUDE, circuit=circuit
)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
except BSBLANAuthError as err:
@@ -140,7 +146,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
)
return BSBLanFastData(
state=state,
states=states,
sensor=sensor,
dhw=dhw,
)
@@ -20,13 +20,20 @@ async def async_get_config_entry_diagnostics(
"info": data.info.model_dump(),
"device": data.device.model_dump(),
"fast_coordinator_data": {
"state": data.fast_coordinator.data.state.model_dump(),
"states": {
str(circuit): state.model_dump()
for circuit, state in data.fast_coordinator.data.states.items()
},
"sensor": data.fast_coordinator.data.sensor.model_dump(),
"dhw": data.fast_coordinator.data.dhw.model_dump()
if data.fast_coordinator.data.dhw
else None,
},
"static": data.static.model_dump() if data.static is not None else None,
"static": {
str(circuit): static.model_dump() if static is not None else None
for circuit, static in data.static.items()
},
"available_circuits": data.available_circuits,
}
# Add DHW config and schedule from slow coordinator if available
+55 -30
View File
@@ -2,17 +2,11 @@
from __future__ import annotations
from yarl import URL
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from . import BSBLanData, get_bsblan_device_info
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
@@ -27,28 +21,8 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
super().__init__(coordinator)
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
mac = data.device.MAC
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac)},
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=(
data.info.device_identification.value
if data.info.device_identification
and data.info.device_identification.value
else None
),
model_id=(
f"{data.info.controller_family.value}_{data.info.controller_variant.value}"
if data.info.controller_family
and data.info.controller_variant
and data.info.controller_family.value
and data.info.controller_variant.value
else None
),
sw_version=data.device.version,
configuration_url=str(URL.build(scheme="http", host=host, port=port)),
self._attr_device_info = get_bsblan_device_info(
data.device, data.info, host, port
)
@@ -60,6 +34,32 @@ class BSBLanEntity(BSBLanEntityBase[BSBLanFastCoordinator]):
super().__init__(coordinator, data)
class BSBLanCircuitEntity(BSBLanEntity):
"""BSBLan entity belonging to a heating circuit sub-device."""
def __init__(
self,
coordinator: BSBLanFastCoordinator,
data: BSBLanData,
circuit: int,
) -> None:
"""Initialize BSBLan circuit entity with sub-device info."""
super().__init__(coordinator, data)
mac = data.device.MAC
host = coordinator.config_entry.data[CONF_HOST]
port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")},
translation_key="heating_circuit",
translation_placeholders={"circuit": str(circuit)},
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
class BSBLanDualCoordinatorEntity(BSBLanEntity):
"""Entity that listens to both fast and slow coordinators."""
@@ -80,3 +80,28 @@ class BSBLanDualCoordinatorEntity(BSBLanEntity):
self.async_on_remove(
self.slow_coordinator.async_add_listener(self._handle_coordinator_update)
)
class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity):
"""BSBLan entity belonging to the water heater sub-device."""
def __init__(
self,
fast_coordinator: BSBLanFastCoordinator,
slow_coordinator: BSBLanSlowCoordinator,
data: BSBLanData,
) -> None:
"""Initialize BSBLan water heater sub-device entity."""
super().__init__(fast_coordinator, slow_coordinator, data)
mac = data.device.MAC
host = fast_coordinator.config_entry.data[CONF_HOST]
port = fast_coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT)
main_info = get_bsblan_device_info(data.device, data.info, host, port)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{mac}-water-heater")},
translation_key="water_heater",
via_device=(DOMAIN, mac),
manufacturer=main_info["manufacturer"],
model=main_info.get("model"),
model_id=main_info.get("model_id"),
)
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.1.4"],
"requirements": ["python-bsblan==5.2.0"],
"zeroconf": [
{
"name": "bsb-lan*",
@@ -48,13 +48,10 @@ rules:
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration provides a limited number of entities, all of which are useful to users.
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: todo
@@ -66,7 +63,7 @@ rules:
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
Devices and sub-devices are determined at config entry setup and do not change at runtime.
# Platinum
async-dependency: done
@@ -79,6 +79,14 @@
}
}
},
"device": {
"heating_circuit": {
"name": "Heating circuit {circuit}"
},
"water_heater": {
"name": "Water heater"
}
},
"entity": {
"button": {
"sync_time": {
@@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import DOMAIN
from .entity import BSBLanDualCoordinatorEntity
from .entity import BSBLanWaterHeaterDeviceEntity
PARALLEL_UPDATES = 1
@@ -61,7 +61,7 @@ async def async_setup_entry(
async_add_entities([BSBLANWaterHeater(data)])
class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity):
class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity):
"""Defines a BSBLAN water heater entity."""
_attr_name = None
+24 -2
View File
@@ -15,7 +15,10 @@ from aiohttp import web
from dateutil.rrule import rrulestr
import voluptuous as vol
from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ
from homeassistant.components import frontend, http, websocket_api
from homeassistant.components.http import KEY_HASS_USER
from homeassistant.components.websocket_api import (
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
@@ -32,7 +35,7 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity import Entity, EntityDescription
@@ -786,6 +789,10 @@ class CalendarEventView(http.HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.Response:
"""Return calendar events."""
user: User = request[KEY_HASS_USER]
if not user.permissions.check_entity(entity_id, POLICY_READ):
raise Unauthorized(entity_id=entity_id)
if not (entity := self.component.get_entity(entity_id)) or not isinstance(
entity, CalendarEntity
):
@@ -837,10 +844,14 @@ class CalendarListView(http.HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
"""Retrieve calendar list."""
user: User = request[KEY_HASS_USER]
hass = request.app[http.KEY_HASS]
entity_perm = user.permissions.check_entity
calendar_list: list[dict[str, str]] = []
for entity in self.component.entities:
if not entity_perm(entity.entity_id, POLICY_READ):
continue
state = hass.states.get(entity.entity_id)
assert state
calendar_list.append({"name": state.name, "entity_id": entity.entity_id})
@@ -860,6 +871,9 @@ async def handle_calendar_event_create(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle creation of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
return
@@ -899,6 +913,8 @@ async def handle_calendar_event_delete(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle delete of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
@@ -944,7 +960,10 @@ async def handle_calendar_event_delete(
async def handle_calendar_event_update(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle creation of a calendar event."""
"""Handle update of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
return
@@ -989,6 +1008,9 @@ async def handle_calendar_event_subscribe(
"""Subscribe to calendar event updates."""
entity_id: str = msg["entity_id"]
if not connection.user.permissions.check_entity(entity_id, POLICY_READ):
raise Unauthorized(entity_id=entity_id)
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
connection.send_error(
msg["id"],
@@ -926,6 +926,7 @@ async def websocket_get_prefs(
vol.Optional(PREF_ORIENTATION): vol.Coerce(Orientation),
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_update_prefs(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+1
View File
@@ -374,6 +374,7 @@ class CloudClient(Interface):
method=payload["method"],
query_string=payload["query"],
mock_source=DOMAIN,
remote=None, # Remote will be used for the local_only check, but since this is from the cloud we want it to be None to mark it as non-local and bypass the ip parsing and remote checks
)
response = await webhook.async_handle_webhook(
@@ -615,6 +615,7 @@ class DownloadSupportPackageView(HomeAssistantView):
return markdown
@require_admin
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""
@@ -709,6 +710,7 @@ def _require_cloud_login(
return with_cloud_auth
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command({vol.Required("type"): "cloud/subscription"})
@websocket_api.async_response
@@ -750,6 +752,7 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]:
return value
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
@@ -809,6 +812,7 @@ async def websocket_update_prefs(
connection.send_message(websocket_api.result_message(msg["id"]))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
@@ -829,6 +833,7 @@ async def websocket_hook_create(
connection.send_message(websocket_api.result_message(msg["id"], hook))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.websocket_command(
{
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.2"]
"requirements": ["aiocomelit==2.0.3"]
}
+6 -16
View File
@@ -10,32 +10,19 @@ from homeassistant.auth.models import User
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
WS_TYPE_LIST = "config/auth/list"
SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_LIST}
)
WS_TYPE_DELETE = "config/auth/delete"
SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
{vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str}
)
@callback
def async_setup(hass: HomeAssistant) -> bool:
"""Enable the Home Assistant views."""
websocket_api.async_register_command(
hass, WS_TYPE_LIST, websocket_list, SCHEMA_WS_LIST
)
websocket_api.async_register_command(
hass, WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE
)
websocket_api.async_register_command(hass, websocket_list)
websocket_api.async_register_command(hass, websocket_delete)
websocket_api.async_register_command(hass, websocket_create)
websocket_api.async_register_command(hass, websocket_update)
return True
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "config/auth/list"})
@websocket_api.async_response
async def websocket_list(
hass: HomeAssistant,
@@ -49,6 +36,9 @@ async def websocket_list(
@websocket_api.require_admin
@websocket_api.websocket_command(
{vol.Required("type"): "config/auth/delete", vol.Required("user_id"): str}
)
@websocket_api.async_response
async def websocket_delete(
hass: HomeAssistant,
@@ -14,6 +14,8 @@ from datetime import datetime
import functools as ft
from typing import Any
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME
from homeassistant.core import (
HassJob,
@@ -24,6 +26,7 @@ from homeassistant.core import (
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import run_callback_threadsafe
@@ -149,8 +152,12 @@ class Configurator:
self._requests: dict[
str, tuple[str, list[dict[str, str]], ConfiguratorCallback | None]
] = {}
hass.services.async_register(
DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call
async_register_admin_service(
hass,
DOMAIN,
SERVICE_CONFIGURE,
self.async_handle_service_call,
schema=vol.Schema({}, extra=vol.ALLOW_EXTRA),
)
@async_callback
+6 -1
View File
@@ -4,7 +4,11 @@ from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.condition import Condition, EntityConditionBase
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityConditionBase,
)
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .models import CoverDomainSpec
@@ -14,6 +18,7 @@ class CoverConditionBase(EntityConditionBase):
"""Base condition for cover state checks."""
_domain_specs: Mapping[str, CoverDomainSpec]
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected cover state."""
@@ -8,6 +8,11 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
awning_is_closed:
fields: *condition_common_fields
@@ -1,6 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
@@ -10,6 +11,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Awning is closed"
@@ -19,6 +23,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Awning is open"
@@ -28,6 +35,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Blind is closed"
@@ -37,6 +47,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Blind is open"
@@ -46,6 +59,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Curtain is closed"
@@ -55,6 +71,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Curtain is open"
@@ -64,6 +83,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Shade is closed"
@@ -73,6 +95,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Shade is open"
@@ -82,6 +107,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Shutter is closed"
@@ -91,6 +119,9 @@
"fields": {
"behavior": {
"name": "[%key:component::cover::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::cover::common::condition_for_name%]"
}
},
"name": "Shutter is open"
+3 -1
View File
@@ -11,6 +11,7 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.util.read_only_dict import ReadOnlyDict
from .const import CONF_BRIDGE_ID, DOMAIN, LOGGER
@@ -98,7 +99,8 @@ def async_setup_services(hass: HomeAssistant) -> None:
await async_remove_orphaned_entries_service(hub)
for service in SUPPORTED_SERVICES:
hass.services.async_register(
async_register_admin_service(
hass,
DOMAIN,
service,
async_call_deconz_service,
@@ -19,7 +19,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
PLATFORMS = [Platform.LIGHT]
@@ -40,7 +40,7 @@ def _login_and_get_switches(email: str, password: str) -> DecoraWifiData:
success = session.login(email, password)
if success is None:
raise ConfigEntryAuthFailed("Invalid credentials for myLeviton account")
raise ConfigEntryError("Invalid credentials for myLeviton account")
perms = session.user.get_residential_permissions()
all_switches: list[IotSwitch] = []
@@ -187,6 +187,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_attr_translation_key = "derivative"
_attr_should_poll = False
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(
self,
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import (
Condition,
ConditionChecker,
ConditionCheckerType,
ConditionConfig,
)
@@ -54,6 +53,7 @@ class DeviceCondition(Condition):
"""Device condition."""
_config: ConfigType
_platform_checker: ConditionCheckerType
@classmethod
async def async_validate_complete_config(
@@ -87,20 +87,19 @@ class DeviceCondition(Condition):
assert config.options is not None
self._config = config.options
async def async_get_checker(self) -> ConditionChecker:
"""Test a device condition."""
async def async_setup(self) -> None:
"""Set up a device condition."""
platform = await async_get_device_automation_platform(
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
)
platform_checker = platform.async_condition_from_config(
self._platform_checker = platform.async_condition_from_config(
self._hass, self._config
)
def checker(variables: TemplateVarsType = None, **kwargs: Any) -> bool:
result = platform_checker(self._hass, variables)
return result is not False
return checker
def _async_check(self, variables: TemplateVarsType = None, **kwargs: Any) -> bool:
"""Check the condition."""
result = self._platform_checker(self._hass, variables)
return result is not False
CONDITIONS: dict[str, type[Condition]] = {
@@ -245,6 +245,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView):
extra_urls = ["/api/diagnostics/{d_type}/{d_id}/{sub_type}/{sub_id}"]
name = "api:diagnostics"
@http.require_admin
async def get(
self,
request: web.Request,
+14 -7
View File
@@ -30,12 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return False
if config_entry.version < 2 and config_entry.minor_version < 2:
version = config_entry.version
minor_version = config_entry.minor_version
_LOGGER.debug(
"Migrating configuration from version %s.%s",
version,
minor_version,
config_entry.version,
config_entry.minor_version,
)
new_options = {**config_entry.options}
@@ -46,10 +44,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry, options=new_options, minor_version=2
)
_LOGGER.debug("Migration to configuration version %s.%s successful", 1, 2)
if config_entry.version < 2 and config_entry.minor_version < 3:
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
1,
2,
"Migrating configuration from version %s.%s",
config_entry.version,
config_entry.minor_version,
)
hass.config_entries.async_update_entry(
config_entry, unique_id=None, minor_version=3
)
_LOGGER.debug("Migration to configuration version %s.%s successful", 1, 3)
return True
@@ -93,7 +93,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for dnsip integration."""
VERSION = 1
MINOR_VERSION = 2
MINOR_VERSION = 3
@staticmethod
@callback
@@ -133,8 +133,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
):
errors["base"] = "invalid_hostname"
else:
await self.async_set_unique_id(hostname)
self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_HOSTNAME: hostname})
return self.async_create_entry(
title=name,
@@ -8,6 +8,11 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_closed:
fields: *condition_common_fields
@@ -1,6 +1,7 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
@@ -10,6 +11,9 @@
"fields": {
"behavior": {
"name": "[%key:component::door::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::door::common::condition_for_name%]"
}
},
"name": "Door is closed"
@@ -19,6 +23,9 @@
"fields": {
"behavior": {
"name": "[%key:component::door::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::door::common::condition_for_name%]"
}
},
"name": "Door is open"
+3 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from duco import DucoClient
from duco import DucoClient, build_ssl_context
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
@@ -14,9 +14,11 @@ from .coordinator import DucoConfigEntry, DucoCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
ssl_context = await hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+3 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from typing import Any
from duco import DucoClient
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
@@ -160,9 +160,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+13 -6
View File
@@ -2,14 +2,17 @@
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
from duco.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
TO_REDACT = {
@@ -32,11 +35,15 @@ async def async_get_config_entry_diagnostics(
board = asdict(coordinator.board_info)
board.pop("time")
lan_info, duco_diags, write_remaining = await asyncio.gather(
coordinator.client.async_get_lan_info(),
coordinator.client.async_get_diagnostics(),
coordinator.client.async_get_write_req_remaining(),
)
try:
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_req_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
return async_redact_data(
{
+1 -1
View File
@@ -35,7 +35,7 @@ PRESET_AUTO = "auto"
# again always round-trips to the same Duco state.
_SPEED_LEVEL_PERCENTAGES: list[int] = [
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS)
]
# Maps every active Duco state (including timed MAN variants) to its
+1 -1
View File
@@ -13,7 +13,7 @@
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.4"],
"requirements": ["python-duco-client==0.3.9"],
"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][]].*",
+32 -2
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from duco.models import Node, NodeType, VentilationState
@@ -18,6 +19,7 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
@@ -27,6 +29,8 @@ from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -56,6 +60,25 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
@@ -79,7 +102,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda node: node.sensor.rh if node.sensor else None,
node_types=(NodeType.BSRH,),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="iaq_rh",
@@ -88,7 +111,7 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.iaq_rh if node.sensor else None,
node_types=(NodeType.BSRH,),
node_types=(NodeType.BSRH, NodeType.UCRH),
),
)
@@ -144,6 +167,13 @@ async def async_setup_entry(
if node.node_id in known_nodes:
continue
known_nodes.add(node.node_id)
if node.general.node_type == NodeType.UNKNOWN:
_LOGGER.warning(
"Duco node %s (%s) has an unsupported device type and will be ignored",
node.node_id,
node.general.name,
)
continue
new_entities.extend(
DucoSensorEntity(coordinator, node, description)
for description in SENSOR_DESCRIPTIONS
@@ -47,6 +47,9 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
@@ -84,6 +87,9 @@
"cannot_connect": {
"message": "An error occurred while trying to connect to the Duco instance: {error}"
},
"connection_error": {
"message": "Could not connect to the Duco device."
},
"failed_to_set_state": {
"message": "Failed to set ventilation state: {error}"
},
@@ -6,6 +6,7 @@ from datetime import timedelta
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from .coordinator import EasyEnergyConfigEntry, EasyEnergyData
@@ -23,9 +24,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
"""
if not data.gas_today:
return None
return data.gas_today.price_at_time(
data.gas_today.utcnow() + timedelta(hours=hours)
)
return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours))
async def async_get_config_entry_diagnostics(
@@ -40,21 +39,21 @@ async def async_get_config_entry_diagnostics(
"title": entry.title,
},
"energy_usage": {
"current_hour_price": energy_today.current_usage_price,
"current_hour_price": energy_today.current_price,
"next_hour_price": energy_today.price_at_time(
energy_today.utcnow() + timedelta(hours=1)
dt_util.utcnow() + timedelta(hours=1)
),
"average_price": energy_today.average_usage_price,
"max_price": energy_today.extreme_usage_prices[1],
"min_price": energy_today.extreme_usage_prices[0],
"highest_price_time": energy_today.highest_usage_price_time,
"lowest_price_time": energy_today.lowest_usage_price_time,
"percentage_of_max": energy_today.pct_of_max_usage,
"average_price": energy_today.average_price,
"max_price": energy_today.extreme_prices[1],
"min_price": energy_today.extreme_prices[0],
"highest_price_time": energy_today.highest_price_time,
"lowest_price_time": energy_today.lowest_price_time,
"percentage_of_max": energy_today.pct_of_max,
},
"energy_return": {
"current_hour_price": energy_today.current_return_price,
"next_hour_price": energy_today.price_at_time(
energy_today.utcnow() + timedelta(hours=1), "return"
"next_hour_price": energy_today.return_price_at_time(
dt_util.utcnow() + timedelta(hours=1)
),
"average_price": energy_today.average_return_price,
"max_price": energy_today.extreme_return_prices[1],
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==2.2.0"],
"requirements": ["easyenergy==3.0.0"],
"single_config_entry": true
}
+14 -15
View File
@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES
from .coordinator import (
@@ -63,7 +64,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = (
service_type="today_energy_usage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.current_usage_price,
value_fn=lambda data: data.energy_today.current_price,
),
EasyEnergySensorEntityDescription(
key="next_hour_price",
@@ -71,7 +72,7 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = (
service_type="today_energy_usage",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.price_at_time(
data.energy_today.utcnow() + timedelta(hours=1)
dt_util.utcnow() + timedelta(hours=1)
),
),
EasyEnergySensorEntityDescription(
@@ -79,42 +80,42 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = (
translation_key="average_price",
service_type="today_energy_usage",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.average_usage_price,
value_fn=lambda data: data.energy_today.average_price,
),
EasyEnergySensorEntityDescription(
key="max_price",
translation_key="max_price",
service_type="today_energy_usage",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.extreme_usage_prices[1],
value_fn=lambda data: data.energy_today.extreme_prices[1],
),
EasyEnergySensorEntityDescription(
key="min_price",
translation_key="min_price",
service_type="today_energy_usage",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.extreme_usage_prices[0],
value_fn=lambda data: data.energy_today.extreme_prices[0],
),
EasyEnergySensorEntityDescription(
key="highest_price_time",
translation_key="highest_price_time",
service_type="today_energy_usage",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.energy_today.highest_usage_price_time,
value_fn=lambda data: data.energy_today.highest_price_time,
),
EasyEnergySensorEntityDescription(
key="lowest_price_time",
translation_key="lowest_price_time",
service_type="today_energy_usage",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.energy_today.lowest_usage_price_time,
value_fn=lambda data: data.energy_today.lowest_price_time,
),
EasyEnergySensorEntityDescription(
key="percentage_of_max",
translation_key="percentage_of_max",
service_type="today_energy_usage",
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.energy_today.pct_of_max_usage,
value_fn=lambda data: data.energy_today.pct_of_max,
),
EasyEnergySensorEntityDescription(
key="current_hour_price",
@@ -129,8 +130,8 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = (
translation_key="next_hour_price",
service_type="today_energy_return",
native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}",
value_fn=lambda data: data.energy_today.price_at_time(
data.energy_today.utcnow() + timedelta(hours=1), "return"
value_fn=lambda data: data.energy_today.return_price_at_time(
dt_util.utcnow() + timedelta(hours=1)
),
),
EasyEnergySensorEntityDescription(
@@ -180,14 +181,14 @@ SENSORS: tuple[EasyEnergySensorEntityDescription, ...] = (
translation_key="hours_priced_equal_or_lower",
service_type="today_energy_usage",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: data.energy_today.hours_priced_equal_or_lower_usage,
value_fn=lambda data: data.energy_today.periods_priced_equal_or_lower,
),
EasyEnergySensorEntityDescription(
key="hours_priced_equal_or_higher",
translation_key="hours_priced_equal_or_higher",
service_type="today_energy_return",
native_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: data.energy_today.hours_priced_equal_or_higher_return,
value_fn=lambda data: data.energy_today.return_periods_priced_equal_or_higher,
),
)
@@ -205,9 +206,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
"""
if data.gas_today is None:
return None
return data.gas_today.price_at_time(
data.gas_today.utcnow() + timedelta(hours=hours)
)
return data.gas_today.price_at_time(dt_util.utcnow() + timedelta(hours=hours))
async def async_setup_entry(
+84 -36
View File
@@ -2,12 +2,13 @@
from __future__ import annotations
from datetime import date, datetime
from datetime import date, datetime, timedelta
from enum import StrEnum
from functools import partial
from typing import Final
from easyenergy import Electricity, Gas, VatOption
from easyenergy import Electricity, Gas, PriceInterval, VatOption
from easyenergy.const import MARKET_TIMEZONE
import voluptuous as vol
from homeassistant.core import (
@@ -32,18 +33,22 @@ ATTR_INCL_VAT: Final = "incl_vat"
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(
{
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
{
"integration": DOMAIN,
}
),
**BASE_SERVICE_SCHEMA,
vol.Required(ATTR_INCL_VAT): bool,
vol.Optional(ATTR_START): str,
vol.Optional(ATTR_END): str,
}
)
RETURN_SERVICE_SCHEMA: Final = vol.Schema(BASE_SERVICE_SCHEMA)
class PriceType(StrEnum):
@@ -54,22 +59,47 @@ class PriceType(StrEnum):
GAS = "gas"
def __get_date(date_input: str | None) -> date | datetime:
"""Get date."""
def __get_date(
date_input: str | None,
) -> tuple[date, datetime | None]:
"""Get date for the API and optional datetime for response filtering."""
if not date_input:
return dt_util.now().date()
return dt_util.now().date(), None
if value := dt_util.parse_datetime(date_input):
return value
if date_value := dt_util.parse_date(date_input):
return date_value, None
raise ServiceValidationError(
"Invalid datetime provided.",
translation_domain=DOMAIN,
translation_key="invalid_date",
translation_placeholders={
"date": date_input,
},
)
if not (datetime_value := dt_util.parse_datetime(date_input)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_date",
translation_placeholders={
"date": date_input,
},
)
datetime_utc = dt_util.as_utc(datetime_value)
return datetime_utc.astimezone(MARKET_TIMEZONE).date(), datetime_utc
def __filter_prices(
prices: list[dict[str, float | datetime]],
intervals: tuple[PriceInterval, ...],
start: datetime,
end: datetime,
) -> list[dict[str, float | datetime]]:
"""Filter prices to the requested datetime range."""
included_timestamps = {
interval.starts_at
for interval in intervals
if interval.ends_at > start and interval.starts_at < end
}
return [
timestamp_price
for timestamp_price in prices
if timestamp_price["timestamp"] in included_timestamps
]
def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse:
@@ -101,8 +131,8 @@ async def __get_prices(
"""Get prices from easyEnergy."""
coordinator = __get_coordinator(call)
start = __get_date(call.data.get(ATTR_START))
end = __get_date(call.data.get(ATTR_END))
start_date, start_datetime = __get_date(call.data.get(ATTR_START))
end_date, end_datetime = __get_date(call.data.get(ATTR_END))
vat = VatOption.INCLUDE
if call.data.get(ATTR_INCL_VAT) is False:
@@ -112,20 +142,38 @@ async def __get_prices(
if price_type == PriceType.GAS:
data = await coordinator.easyenergy.gas_prices(
start_date=start,
end_date=end,
start_date=start_date,
end_date=end_date,
vat=vat,
)
prices = data.timestamp_prices
else:
data = await coordinator.easyenergy.energy_prices(
start_date=start_date,
end_date=end_date,
vat=vat,
)
return __serialize_prices(data.timestamp_prices)
data = await coordinator.easyenergy.energy_prices(
start_date=start,
end_date=end,
vat=vat,
)
if price_type == PriceType.ENERGY_USAGE:
return __serialize_prices(data.timestamp_usage_prices)
return __serialize_prices(data.timestamp_return_prices)
if price_type == PriceType.ENERGY_USAGE:
prices = data.timestamp_prices
else:
prices = data.timestamp_return_prices
if start_datetime or end_datetime:
filter_start = start_datetime or dt_util.as_utc(
dt_util.start_of_local_day(start_date)
)
filter_end = end_datetime or dt_util.as_utc(
dt_util.start_of_local_day(end_date + timedelta(days=1))
)
prices = __filter_prices(
prices,
data.intervals,
filter_start,
filter_end,
)
return __serialize_prices(prices)
@callback
@@ -150,6 +198,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
DOMAIN,
ENERGY_RETURN_SERVICE_NAME,
partial(__get_prices, price_type=PriceType.ENERGY_RETURN),
schema=SERVICE_SCHEMA,
schema=RETURN_SERVICE_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
@@ -213,11 +213,13 @@ ECOWITT_SENSORS_MAPPING: Final = {
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_KM: SensorEntityDescription(
key="LIGHTNING_DISTANCE_KM",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.LIGHTNING_DISTANCE_MILES: SensorEntityDescription(
key="LIGHTNING_DISTANCE_MILES",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILES,
state_class=SensorStateClass.MEASUREMENT,
),
@@ -11,6 +11,7 @@
"documentation": "https://www.home-assistant.io/integrations/elgato",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["elgato==5.1.2"],
"zeroconf": ["_elg._tcp.local."]
}
@@ -10,7 +10,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
@@ -25,8 +25,8 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -41,17 +41,13 @@ rules:
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices:
status: todo
comment: |
Device are documented, but some are missing. For example, the their pro
strip is supported as well.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
+5 -1
View File
@@ -17,6 +17,7 @@ from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
__version__ as ha_version,
)
from homeassistant.core import HomeAssistant, callback
@@ -80,7 +81,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if "usb" in hass.config.components:
async_register_serial_port_scanner(hass, _async_scan_serial_ports)
serial_proxy.set_hass_loop(hass.loop)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP,
serial_proxy.register_serialx_transport(hass.loop),
)
return True
+10 -1
View File
@@ -1,11 +1,20 @@
"""ESPHome constants."""
from typing import Final
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from awesomeversion import AwesomeVersion
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .domain_data import DomainData
DOMAIN = "esphome"
ESPHOME_DATA: HassKey[DomainData] = HassKey(DOMAIN)
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
@@ -4,12 +4,11 @@ from __future__ import annotations
from dataclasses import dataclass, field
from functools import cache
from typing import Self
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from .const import DOMAIN
from .const import ESPHOME_DATA
from .entry_data import ESPHomeConfigEntry, ESPHomeStorage, RuntimeEntryData
STORAGE_VERSION = 1
@@ -36,11 +35,9 @@ class DomainData:
),
)
@classmethod
@staticmethod
@cache
def get(cls, hass: HomeAssistant) -> Self:
def get(hass: HomeAssistant) -> DomainData:
"""Get the global DomainData instance stored in hass.data."""
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=hass-use-runtime-data
ret = hass.data[DOMAIN] = cls()
ret = hass.data[ESPHOME_DATA] = DomainData()
return ret
@@ -35,6 +35,7 @@ from aioesphomeapi import (
MediaPlayerInfo,
MediaPlayerSupportedFormat,
NumberInfo,
RadioFrequencyInfo,
SelectInfo,
SensorInfo,
SensorState,
@@ -88,6 +89,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
FanInfo: Platform.FAN,
InfraredInfo: Platform.INFRARED,
LightInfo: Platform.LIGHT,
RadioFrequencyInfo: Platform.RADIO_FREQUENCY,
LockInfo: Platform.LOCK,
MediaPlayerInfo: Platform.MEDIA_PLAYER,
NumberInfo: Platform.NUMBER,
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.18.0",
"aioesphomeapi==44.21.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
@@ -0,0 +1,77 @@
"""Radio Frequency platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import (
EntityState,
RadioFrequencyCapability,
RadioFrequencyInfo,
RadioFrequencyModulation,
)
from rf_protocols import ModulationType, RadioFrequencyCommand
from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
MODULATION_TYPE_TO_ESPHOME: dict[ModulationType, RadioFrequencyModulation] = {
ModulationType.OOK: RadioFrequencyModulation.OOK,
}
class EsphomeRadioFrequencyEntity(
EsphomeEntity[RadioFrequencyInfo, EntityState], RadioFrequencyTransmitterEntity
):
"""ESPHome radio frequency entity using native API."""
@property
def supported_frequency_ranges(self) -> list[tuple[int, int]]:
"""Return supported frequency ranges from device info."""
return [(self._static_info.frequency_min, self._static_info.frequency_max)]
@callback
def _on_device_update(self) -> None:
"""Call when device updates or entry data changes."""
super()._on_device_update()
if self._entry_data.available:
self.async_write_ha_state()
@convert_api_error_ha_error
async def async_send_command(self, command: RadioFrequencyCommand) -> None:
"""Send an RF command."""
timings = command.get_raw_timings()
_LOGGER.debug("Sending RF command: %s", timings)
self._client.radio_frequency_transmit_raw_timings(
self._static_info.key,
frequency=command.frequency,
timings=timings,
modulation=MODULATION_TYPE_TO_ESPHOME[command.modulation],
# In ESPHome, repeat_count is total number of times to send the command, while in rf_protocols
# it's the number of additional times to send it, so we need to add 1 here.
repeat_count=command.repeat_count + 1,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=RadioFrequencyInfo,
entity_type=EsphomeRadioFrequencyEntity,
state_type=EntityState,
info_filter=lambda info: bool(
info.capabilities & RadioFrequencyCapability.TRANSMITTER
),
)

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