Compare commits

...

181 Commits

Author SHA1 Message Date
Claude
aa6c2b4f74 Rename skill reference files to uppercase 2026-01-22 03:53:26 +00:00
Claude
e0349cae63 Add Home Assistant integration development skill
Create a Claude Code skill for developing Home Assistant integrations with
separate reference files for each platform and component:

- SKILL.md: Main skill entry point with quick reference table
- config-flow.md: Configuration flow patterns
- coordinator.md: Data update coordinator patterns
- entity.md: Base entity development patterns
- sensor.md: Sensor platform reference
- binary-sensor.md: Binary sensor platform reference
- switch.md: Switch platform reference
- number.md: Number platform reference
- select.md: Select platform reference
- button.md: Button platform reference
- device.md: Device management patterns
- diagnostics.md: Diagnostics implementation
- services.md: Service registration patterns
- testing.md: Testing patterns and fixtures

This skill extracts best practices from CLAUDE.md into a reusable
format that can be invoked via /ha-integration.
2026-01-22 03:30:27 +00:00
J. Nick Koston
83a53dea94 Fix SSL context mutation by httpx/httpcore with ALPN protocol bucketing (#161330) 2026-01-21 16:53:38 -10:00
Joost Lekkerkerker
4fb89e68a7 Add integration_type hub to sanix (#161322) 2026-01-21 23:18:32 +01:00
Glenn de Haan
5202ddf095 Bump hdfury to 1.4.2 (#161401) 2026-01-21 23:06:06 +01:00
Marc Mueller
f7d7a4502e Update ruff to 0.14.13 (#161399) 2026-01-21 22:43:26 +01:00
Petro31
c7417d77b5 Update template select test framework (#161389) 2026-01-21 22:31:00 +01:00
Petro31
22018f1f80 Update template number tests to new framework (#161395) 2026-01-21 22:30:13 +01:00
Raphael Hehl
22c6704d81 Fix detection of multiple smart object types in single event (#161189)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-21 22:22:34 +01:00
Raphael Hehl
0552934b3c Bump uiprotect to 10.0.1 (#161397)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-21 22:20:33 +01:00
Joost Lekkerkerker
bbe1d28e88 Refactor GitHub tests to patch the library instead (#160568) 2026-01-21 22:09:56 +01:00
Robert Resch
b700a27c8f Enable apple tv on Python 3.14 (#161396) 2026-01-21 21:56:51 +01:00
Joost Lekkerkerker
0566a668a9 Add translation for add entry to RDW (#161329) 2026-01-21 21:28:27 +01:00
Marc Mueller
94f636bc2d Update pyatv to 0.17.0 (#161394) 2026-01-21 21:22:26 +01:00
Manu
a6e7546142 Add support for sequence ID to publish action in ntfy integration (#161342) 2026-01-21 17:41:46 +00:00
Thomas55555
493319894b Use device_class for O3 in Google Air Quality (#161380) 2026-01-21 17:34:46 +01:00
Erik Montnemery
987396722b Adjust entity condition strings (#161055) 2026-01-21 16:56:47 +01:00
epenet
4f52b0363d Reorder unit conversion classes alphabetically (#161364) 2026-01-21 15:53:43 +00:00
Daniel Hjelseth Høyer
52e18ed6f6 Simplify tibber config (#160903) 2026-01-21 15:42:25 +01:00
Abílio Costa
4180175fd3 Improve automation variable name (#161340) 2026-01-21 14:27:18 +00:00
Maciej Bieniek
e39ee8cae7 Bump imgw_pib to 2.0.1 (#161376) 2026-01-21 15:26:29 +01:00
epenet
c214193087 Reorder recorder constants alphabetically (#161363) 2026-01-21 14:07:24 +01:00
Paulus Schoutsen
2d84847be5 Migrate apps added to sidebar to use new app panel (#161265)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-21 14:59:13 +02:00
Joost Lekkerkerker
0d69fd4535 Add integration_type hub to rainbird (#161303) 2026-01-21 13:42:24 +01:00
Joost Lekkerkerker
56f556864c Add integration_type hub to sensibo (#161326) 2026-01-21 13:41:18 +01:00
Manu
c1b03dc553 Bump aiontfy to 0.7.0 (#161341) 2026-01-21 13:40:56 +01:00
epenet
07e76578e6 Add translation for add entry to Renault (#161361) 2026-01-21 13:40:25 +01:00
Manu
bc45fd4e45 Add translation for add entry to Habitica integration (#161372) 2026-01-21 13:39:56 +01:00
Abílio Costa
0ea03f549c Support target conditions in script relation extraction (#161338) 2026-01-21 12:01:15 +00:00
Robert Resch
0ee46dbf5d Replace deprecated test-results-action action with codecov-action (#159202) 2026-01-21 12:33:32 +01:00
Thomas55555
e12f394f8e Bump google-air-quality-api to 3.0.0 (#161347) 2026-01-21 08:49:20 +01:00
Thomas55555
b40046264d Add ppb as a valid UOM for sensor/number Ozone device class (#159328)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-21 08:32:23 +01:00
Thomas55555
22afa1d248 Use device_class for NO2 in Google Air Quality (#161359) 2026-01-21 07:51:35 +01:00
Josef Zweck
8920ffbcdb Add translation for add entry to onedrive (#161336) 2026-01-21 07:06:22 +01:00
Thomas55555
a447c1b42e Use SO2 device_class in Google Air Quality (#161349) 2026-01-21 06:55:02 +01:00
Raphael Hehl
50211f75ed Bump uiprotect to 10.0.0 (#161350) 2026-01-21 00:59:46 +01:00
Thomas55555
27117c9d17 Add ppb as a valid UOM for sensor/number NO2 device class (#159426)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-01-20 22:35:11 +00:00
Scott K Logan
7c4cdd57b6 Set integration_type for rainforest_raven to 'hub' (#161343) 2026-01-20 21:46:47 +01:00
Josef Zweck
6af5698645 Bump onedrive-personal-sdk to 0.1.1 (#161337) 2026-01-20 20:14:58 +00:00
Erik Montnemery
75db2cde40 Improve light brightness triggers (#161233) 2026-01-20 20:14:15 +00:00
stegm
329dd05434 Bump pykoplenti to 1.5.0 (#161305) 2026-01-20 21:12:49 +01:00
Joost Lekkerkerker
53c53d03e0 Add integration_type hub to rituals_perfume_genie (#161312) 2026-01-20 21:10:11 +01:00
Joost Lekkerkerker
360b394d03 Add integration_type hub to rfxtrx (#161311) 2026-01-20 21:09:09 +01:00
Joost Lekkerkerker
a663d55632 Add integration_type device to renson (#161310) 2026-01-20 21:07:50 +01:00
Joost Lekkerkerker
3fd266a513 Add integration_type hub to rehlko (#161309) 2026-01-20 21:07:21 +01:00
Joost Lekkerkerker
442c1d6242 Add integration_type hub to refoss (#161308) 2026-01-20 21:06:51 +01:00
Joost Lekkerkerker
0e2aae02f6 Add integration_type device to rapt_ble (#161307) 2026-01-20 21:04:45 +01:00
Joost Lekkerkerker
3227a6e49f Add integration_type device to rainforest_raven (#161306) 2026-01-20 21:04:10 +01:00
Joost Lekkerkerker
9d0cfb628b Add integration_type device to radiotherm (#161302) 2026-01-20 21:00:50 +01:00
Joost Lekkerkerker
4578fe0260 Add integration_type device to rabbitair (#161300) 2026-01-20 20:55:41 +01:00
Joost Lekkerkerker
0d92708108 Add integration_type device to qnap_qsw (#161299) 2026-01-20 20:54:58 +01:00
Joost Lekkerkerker
cceb50071b Add integration_type device to ruuvitag_ble (#161319) 2026-01-20 20:53:14 +01:00
Joost Lekkerkerker
62f296c9dd Add integration_type device to ruuvi_gateway (#161318) 2026-01-20 20:52:32 +01:00
Joost Lekkerkerker
ea1f280494 Add integration_type hub to russound_rio (#161317) 2026-01-20 20:48:32 +01:00
Joost Lekkerkerker
67108a2fc8 Add integration_type service to rova (#161316) 2026-01-20 20:47:36 +01:00
Joost Lekkerkerker
1ccbd5124e Add integration_type hub to roon (#161315) 2026-01-20 20:47:07 +01:00
Joost Lekkerkerker
818af90a7b Add integration_type device to roomba (#161314) 2026-01-20 20:45:35 +01:00
Joost Lekkerkerker
23bc78fa25 Add integration_type device to romy (#161313) 2026-01-20 20:44:38 +01:00
Josef Zweck
0b1cc7638f Enable smart chunk size in onedrive (#161170) 2026-01-20 20:41:48 +01:00
Joost Lekkerkerker
c291a2fbc1 Add translation for add entry to NYT Games (#161327) 2026-01-20 20:35:50 +01:00
Joost Lekkerkerker
7379a4ff4b Add integration_type hub to sense (#161325) 2026-01-20 20:33:18 +01:00
Joost Lekkerkerker
ddcf5cb749 Add integration_type hub to schlage (#161323) 2026-01-20 20:29:23 +01:00
Joost Lekkerkerker
4b10a542b0 Add integration_type hub to rympro (#161320) 2026-01-20 20:27:35 +01:00
Joost Lekkerkerker
beea9fa74b Add integration_type service to sabnzbd (#161321) 2026-01-20 20:20:54 +01:00
Joost Lekkerkerker
ce8fd16456 Add translation for add entry to Twitch (#161332) 2026-01-20 20:11:20 +01:00
Joost Lekkerkerker
2172d15489 Add translation for add entry to SmartThings (#161331) 2026-01-20 20:10:47 +01:00
Joost Lekkerkerker
0cfa0ed670 Add translation for add entry to Withings (#161333) 2026-01-20 20:07:54 +01:00
Joost Lekkerkerker
f6839913d8 Add translation for add entry to Youtube (#161334) 2026-01-20 20:07:33 +01:00
Manu
8fa01497ee Add translation for add entry to Xbox integration (#161296) 2026-01-20 19:14:49 +01:00
Abílio Costa
e077c65a77 Support target conditions in automation relation extraction (#161016) 2026-01-20 17:34:21 +00:00
Manu
7c49656fa8 Add translation for add entry to PlayStation Network integration (#161298) 2026-01-20 18:29:23 +01:00
Erik Montnemery
1730479c8d Remove reference of removed stub_blueprint_populate fixture from siren tests (#161294) 2026-01-20 16:16:22 +01:00
Thomas55555
bc28c8fd3c Add ppb as a valid uom for sensor/number CO device class (#159554) 2026-01-20 16:07:24 +01:00
Erik Montnemery
c3616fd5df Add siren conditions (#161021) 2026-01-20 16:05:21 +01:00
epenet
6b97f2ac06 Use shorthand attributes in wyoming TTS (#161286) 2026-01-20 15:59:49 +01:00
Erik Montnemery
deefcbcbe4 Remove stub_blueprint_populate test fixture (#161288) 2026-01-20 15:46:06 +01:00
Samuel Xiao
e84aeb9f99 Switchbot Cloud: Add new supported Lock (#161276) 2026-01-20 15:27:27 +01:00
epenet
ade3d8a657 Pass timestamps to Tuya wrapper skip_update (#161271) 2026-01-20 15:24:49 +01:00
Krisjanis Lejejs
a65d9032ff Bump hass-nabucasa from 1.10.0 to 1.11.0 (#161283) 2026-01-20 14:50:00 +01:00
Martin Hjelmare
b950a4eaf4 Fix nobo_hub options flow unload mocking (#161287) 2026-01-20 14:49:46 +01:00
Joost Lekkerkerker
3fe91751f5 Make add entry translatable (#159901) 2026-01-20 14:25:33 +01:00
Erwin Douna
6ee58b96ca Bump pyfirefly to 0.1.12 (#161278) 2026-01-20 13:51:09 +01:00
Erik Montnemery
d1404e7905 Simplify logic in condition tests (#161239) 2026-01-20 10:39:47 +01:00
Paulus Schoutsen
7c34191813 Use new app panel instead of ingress page (#161264)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-20 10:54:56 +02:00
PolarBearEs
7540d04779 Remove duplicated MQTT_ORIGIN_INFO_SCHEMA in schemas.py (#161263) 2026-01-20 08:41:40 +01:00
mettolen
d828130670 Bump pysaunum to 0.3.0 (#161255) 2026-01-20 08:31:48 +01:00
Adalberto Garcia Garces
2ec6c08bd7 Add 18 new Tuya device fixtures (#161225) 2026-01-20 07:33:59 +01:00
Thomas55555
48852bab7a Add ppb as a valid UOM for sensor/number SO2 device class (#159431) 2026-01-19 23:32:45 +00:00
Andres Ruiz
7d370f4513 Bump waterfurnace to 1.4.0 (#161244) 2026-01-19 20:33:29 +01:00
Aleksandr Oleinikov
9d97791faf Change default model for Ollama to qwen3:4b-instruct (#161202)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-19 12:56:18 -05:00
Norbert Rittel
4fe8982b68 Clarify description of lawn_mower.docked trigger (#161238) 2026-01-19 17:48:40 +00:00
Krisjanis Lejejs
8248ade211 Bump hass-nabucasa from 1.9.0 to 1.10.0 (#161226) 2026-01-19 15:02:09 +01:00
Brett Adams
572c0e393c Add reconfigure flow in Teslemetry (#160969)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:58:14 +01:00
Colin
d25f2bab9a Increase test coverage in openevse (#160971)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-19 14:44:05 +01:00
Dave Morra
916812dd58 Add trigger for vacuum returning to dock (#158143)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-19 13:34:35 +01:00
Marc Mueller
cea84aa3c8 Fix pytest usefixtures mark in proxmoxve tests (#161177) 2026-01-19 13:06:15 +01:00
Paulus Schoutsen
af83fa809a Add app panel (#157554)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-19 13:08:00 +02:00
epenet
8c997cb6a9 Add selenium to FORBIDDEN_PACKAGE_EXCEPTIONS (#161216) 2026-01-19 10:47:00 +01:00
Jacob
4ccb6e4c8b Fix icons for 'moving' state (#161194) 2026-01-19 10:37:19 +01:00
epenet
37a45b1a92 Use shorthand attributes in qwikswitch sensor/binary_sensor (#161209) 2026-01-19 10:26:21 +01:00
dependabot[bot]
ac84211702 Bump actions/ai-inference from 2.0.4 to 2.0.5 (#161206)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 10:25:42 +01:00
dependabot[bot]
c209ddbb24 Bump actions/cache from 5.0.1 to 5.0.2 (#161207)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 10:18:59 +01:00
epenet
66ab50c737 Fix incorrect device class in wirelesstag binary_sensor (#161215) 2026-01-19 10:14:36 +01:00
Artur Pragacz
46074b0f9c Fix color temperature attributes in wiz (#161125) 2026-01-19 09:35:36 +01:00
Przemko92
56d8913159 Bump compit-inext-api to 0.4.2 (#161162) 2026-01-19 09:33:55 +01:00
epenet
c1bbfec203 Use HassKey in wirelesstag (#161211) 2026-01-19 08:32:39 +01:00
epenet
290c2fd5b6 Use shorthand attributes in w800rf32 binary_sensor (#161210) 2026-01-19 08:32:12 +01:00
Tim Laing
e472180fb2 Bump pyicloud to 2.3.0 (#161164) 2026-01-18 17:36:50 +01:00
David(Xiaobin) Lin
a1ced9a259 Bump xiaomi-ble to 1.5.0 (#161154) 2026-01-18 17:34:51 +01:00
Daniel Hjelseth Høyer
80a700f668 Add more Tibber sensors (#161079)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-01-18 13:44:42 +01:00
mettolen
54fc963297 Add configurable sauna types to Saunum integration (#159782) 2026-01-18 13:43:11 +01:00
Joost Lekkerkerker
59776adeb3 Remove deprecated Homee entity (#161121) 2026-01-18 13:42:03 +01:00
Marc Mueller
af53daa43c Fix vicare DeprecationWarnings (#161161) 2026-01-18 13:40:56 +01:00
tronikos
65123609ea Bump opower to 0.16.4 (#161153) 2026-01-18 10:12:43 +01:00
Manu
847adcf977 Add tests for media player actions in Xbox integration (#161156) 2026-01-18 10:09:05 +01:00
Daniel Hjelseth Høyer
f0dc66cb53 Update Tibber library 0.35.0 (#161139) 2026-01-18 06:43:56 +01:00
Ivan Lopez Hernandez
54275a0ee4 Update Gemini SDK Version (#161137) 2026-01-17 15:21:22 -05:00
Manu
964f36bc50 Assume muted state in Xbox integration (#161118) 2026-01-17 21:05:53 +01:00
Erwin Douna
e83cbc3fc5 Proxmox set integration type (#161141) 2026-01-17 20:59:40 +01:00
Tero Paloheimo
e26d90d82b Bump xiaomi-ble to 1.4.3 (#161132) 2026-01-17 18:33:29 +01:00
Artur Pragacz
da52482365 Add labs to core files (#161126) 2026-01-17 17:01:59 +01:00
Maciej Bieniek
6ba16ee9e9 Create cable unplugged entity only for Shelly Flood Gen4 (#161053) 2026-01-17 16:58:23 +01:00
Glenn de Haan
fa29d8180f Improve quality scale to silver HDFury integration (#161077) 2026-01-17 16:57:25 +01:00
Zach Deibert
5d43efb22d Add support for Minecraft Server Java Edition 1.4 - 1.6 (#161035)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 16:14:17 +01:00
mettolen
3539c4bcec Update Saunum integration to platinum quality (#160824)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 15:44:45 +01:00
Allan Lewis
3e3ec4616c Update list of supported locations for London Air (#160884) 2026-01-17 15:44:34 +01:00
Steve Easley
907861effd Bump pyjvcprojector to 2.0.0 (#160739)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-17 15:44:29 +01:00
Zach Deibert
862a2bc95c Update mcstatus to 12.1.0 (#161124) 2026-01-17 15:41:47 +01:00
DeerMaximum
60f498c1fa Use configuration constants in NINA tests (#161119) 2026-01-17 14:22:32 +01:00
mettolen
bb3617ac08 Add switch entitles to Airobot integration (#161090) 2026-01-17 13:17:22 +01:00
Niracler
48d1bd13fa Add sensor platform support to sunricher_dali integration (#159579) 2026-01-17 13:16:43 +01:00
Josef Zweck
8555bc9da0 Add reauthentication to openai_conversation (#161044)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 13:13:58 +01:00
epenet
9260394883 Mark preset_mode type hints as compulsory in climate/fan platforms (#161043) 2026-01-17 13:09:18 +01:00
DeerMaximum
8503637a80 Patch the NINA library instead of the HTTP requests (#161074) 2026-01-17 13:00:40 +01:00
Erwin Douna
c993cd9bee Add Config Flow for ProxmoxVE (#142432)
Co-authored-by: Robert Resch <robert@resch.dev>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-17 12:59:58 +01:00
epenet
171013c0d0 Improve type hints in nx584 (#161065) 2026-01-17 12:56:31 +01:00
Evan Graham
c8a7aa359e Add gpt-4.1-mini and gpt-4.1-nano to unsupported extended cache retention list (#161097) 2026-01-17 12:16:25 +01:00
Ludovic BOUÉ
88d8951657 Adjust battery voltage sensor display precision for Matter devices (#161088)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-17 11:55:44 +01:00
epenet
b66ab3cf92 Improve type hints in ness_alarm (#161064) 2026-01-17 11:43:14 +01:00
epenet
253b32abd6 Use HassKey in qwikswitch (#161066) 2026-01-17 00:33:30 +01:00
Ludovic BOUÉ
cc20072c86 Fix Matter Window covering config status entity name (#160960) 2026-01-17 00:25:48 +01:00
Bram Kragten
f86db56d48 Update frontend to 20260107.2 (#161061) 2026-01-16 16:44:08 +01:00
Manu
3e2ebb8ebb Fix entity description in Mastodon (#161068) 2026-01-16 16:38:00 +01:00
Artur Pragacz
6e7b206788 Add update preview feature to labs (#160989) 2026-01-16 15:18:06 +01:00
Manu
cee007b0b0 Add binary sensor platform to Mastodon (#161056) 2026-01-16 14:31:42 +01:00
Erwin Douna
bd24c27bc9 SMA add selector strings/translation (#161060) 2026-01-16 13:56:15 +01:00
Andrew Jackson
49bd26da86 Bump aiomealie to 1.2.0 (#161058) 2026-01-16 13:37:22 +01:00
AlCalzone
49c42b9ad0 Clean up unnecessary Z-Wave "device config changed" repairs (#161000) 2026-01-16 12:51:42 +01:00
Josef Zweck
411491dc45 Type OpenAI config entry consistently (#161052) 2026-01-16 11:19:51 +01:00
Erik Montnemery
47383a499e Remove useless @pytest.mark.asyncio decorators from tests (#161050) 2026-01-16 10:19:23 +01:00
Erwin Douna
f9aa307cb2 SMA add reconfigure flow (#160743)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-16 10:16:34 +01:00
epenet
7c6a31861e Improve type hints in egardia (#161048) 2026-01-16 10:08:24 +01:00
Robert Resch
b2b25ca28c Revert "Add SmartThings media-player audio notifications" (#161049) 2026-01-16 10:06:30 +01:00
epenet
ad9efab16a Improve type hints in concord232 (#161045) 2026-01-16 09:46:53 +01:00
Matthias Alphart
e967d33911 Update knx-frontend to 2026.1.15.112308 (#161004) 2026-01-16 09:37:09 +01:00
epenet
86bacdbdde Use shorthand attributes in oasa_telematics (#160990) 2026-01-16 09:34:51 +01:00
Robert Resch
644a40674d Make shebang matcher stricter (#160986) 2026-01-16 09:21:19 +01:00
Raphael Hehl
2cf813758e Add per-camera ring volume control for UniFi Protect chimes (#161031)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-16 08:29:35 +01:00
DeerMaximum
ad47eccf5f Bump pynina to 1.0.2 (#161013) 2026-01-16 08:24:58 +01:00
epenet
581b554a66 Improve type hints in digital_ocean (#161006) 2026-01-16 08:23:13 +01:00
epenet
e4def9eb03 Improve type hints in envisalink (#161005) 2026-01-16 08:22:15 +01:00
epenet
5f2d17faf6 Improve type hints in homematic (#161002) 2026-01-16 08:21:30 +01:00
TheJulianJES
e17565c069 Add Resideo X2S Smart Thermostat diagnostics to Matter fixture (#161037) 2026-01-16 08:20:42 +01:00
Erik Montnemery
b856e04825 Add assist_satellite conditions (#161019) 2026-01-16 07:39:59 +01:00
epenet
67e676df4f Fix duplicate HVACMode in Tuya climate (#160918) 2026-01-15 22:12:24 +01:00
Erik Montnemery
e2e7485e30 Remove unused test fixture from light condition tests (#160925) 2026-01-15 22:03:18 +01:00
Erik Montnemery
043a0b5aa6 Add alarm_control_panel conditions (#160975) 2026-01-15 20:17:02 +01:00
Jaap Pieroen
457af066c8 Decrease Essent update interval to 1 hour (#160959) 2026-01-15 19:42:18 +01:00
Robert Resch
3040fa3412 Require admin for blueprint ws commands (#161008) 2026-01-15 16:46:55 +01:00
epenet
1293e7ed70 Improve type hints in mfi (#160985) 2026-01-15 10:51:32 +01:00
Josef Zweck
3e81cea99f Add descriptions to openai_conversation (#160979)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-01-15 10:40:52 +01:00
Josef Zweck
4ce2dae701 Bump onedrive-personal-sdk to 0.1.0 (#160976) 2026-01-15 10:39:22 +01:00
epenet
a14a8c4e43 Mark last_reset and state_class type hints as compulsory in sensor platform (#160982) 2026-01-15 10:38:34 +01:00
epenet
89e734d2de Improve type hints in ebusd (#160984) 2026-01-15 10:30:58 +01:00
Brett Adams
26c81f29e9 Teslemetry: Add OAuth error handling guards (#160968)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-15 10:28:11 +01:00
Niracler
ce82e88919 Bump PySrDaliGateway from 0.18.0 to 0.19.3 (#160972)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-01-15 09:44:39 +01:00
Erik Montnemery
60316a1232 Deduplicate light condition descriptions (#160977) 2026-01-15 09:14:28 +01:00
Erik Montnemery
aca4d3c5e6 Fix stale and misleading docstrings in alarm_control_panel.trigger (#160978) 2026-01-15 09:08:24 +01:00
epenet
9a93096e4b Move utility_meter service definitions (#160980) 2026-01-15 09:06:18 +01:00
tronikos
3b68aa0776 Bump opower to 0.16.3 (#160961) 2026-01-15 08:29:01 +01:00
Erik Montnemery
6ca60f0260 Update sunricher_dali test snapshots (#160973) 2026-01-15 08:26:46 +01:00
586 changed files with 34495 additions and 17740 deletions

View File

@@ -0,0 +1,238 @@
# Binary Sensor Platform Reference
Binary sensors represent on/off states.
## Basic Binary Sensor
```python
"""Binary sensor platform for My Integration."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities([
DoorSensor(coordinator),
MotionSensor(coordinator),
])
class DoorSensor(MyEntity, BinarySensorEntity):
"""Door open/close sensor."""
_attr_device_class = BinarySensorDeviceClass.DOOR
_attr_translation_key = "door"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_door"
@property
def is_on(self) -> bool | None:
"""Return true if door is open."""
return self.coordinator.data.door_open
```
## Device Classes
Common binary sensor device classes:
| Device Class | On Means | Off Means |
|--------------|----------|-----------|
| `BATTERY` | Low | Normal |
| `BATTERY_CHARGING` | Charging | Not charging |
| `CONNECTIVITY` | Connected | Disconnected |
| `DOOR` | Open | Closed |
| `GARAGE_DOOR` | Open | Closed |
| `LOCK` | Unlocked | Locked |
| `MOISTURE` | Wet | Dry |
| `MOTION` | Motion detected | Clear |
| `OCCUPANCY` | Occupied | Clear |
| `OPENING` | Open | Closed |
| `PLUG` | Plugged in | Unplugged |
| `POWER` | Power detected | No power |
| `PRESENCE` | Present | Away |
| `PROBLEM` | Problem | OK |
| `RUNNING` | Running | Not running |
| `SAFETY` | Unsafe | Safe |
| `SMOKE` | Smoke detected | Clear |
| `SOUND` | Sound detected | Clear |
| `TAMPER` | Tampering | Clear |
| `UPDATE` | Update available | Up-to-date |
| `VIBRATION` | Vibration | Clear |
| `WINDOW` | Open | Closed |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntityDescription,
)
@dataclass(frozen=True, kw_only=True)
class MyBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describe My binary sensor entity."""
is_on_fn: Callable[[MyData], bool | None]
BINARY_SENSORS: tuple[MyBinarySensorEntityDescription, ...] = (
MyBinarySensorEntityDescription(
key="door",
translation_key="door",
device_class=BinarySensorDeviceClass.DOOR,
is_on_fn=lambda data: data.door_open,
),
MyBinarySensorEntityDescription(
key="motion",
translation_key="motion",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda data: data.motion_detected,
),
MyBinarySensorEntityDescription(
key="low_battery",
translation_key="low_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
is_on_fn=lambda data: data.battery_level < 20 if data.battery_level else None,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MyBinarySensor(coordinator, description)
for description in BINARY_SENSORS
)
class MyBinarySensor(MyEntity, BinarySensorEntity):
"""Binary sensor using entity description."""
entity_description: MyBinarySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(self.coordinator.data)
```
## Connectivity Sensor
```python
class ConnectivitySensor(MyEntity, BinarySensorEntity):
"""Device connectivity sensor."""
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "connectivity"
@property
def is_on(self) -> bool:
"""Return true if device is connected."""
return self.coordinator.data.is_connected
```
## Problem Sensor
```python
class ProblemSensor(MyEntity, BinarySensorEntity):
"""Problem indicator sensor."""
_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "problem"
@property
def is_on(self) -> bool:
"""Return true if there's a problem."""
return self.coordinator.data.has_error
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
return {
"error_code": self.coordinator.data.error_code,
"error_message": self.coordinator.data.error_message,
}
```
## Update Available Sensor
```python
class UpdateAvailableSensor(MyEntity, BinarySensorEntity):
"""Firmware update available sensor."""
_attr_device_class = BinarySensorDeviceClass.UPDATE
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "update_available"
@property
def is_on(self) -> bool:
"""Return true if an update is available."""
return self.coordinator.data.update_available
```
## Translations
In `strings.json`:
```json
{
"entity": {
"binary_sensor": {
"door": {
"name": "Door"
},
"motion": {
"name": "Motion"
},
"low_battery": {
"name": "Low battery"
},
"connectivity": {
"name": "Connectivity"
}
}
}
}
```

View File

@@ -0,0 +1,201 @@
# Button Platform Reference
Button entities trigger actions when pressed.
## Basic Button
```python
"""Button platform for My Integration."""
from __future__ import annotations
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons from config entry."""
coordinator = entry.runtime_data
async_add_entities([
RestartButton(coordinator),
IdentifyButton(coordinator),
])
class RestartButton(MyEntity, ButtonEntity):
"""Restart button."""
_attr_device_class = ButtonDeviceClass.RESTART
_attr_translation_key = "restart"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_restart"
async def async_press(self) -> None:
"""Handle the button press."""
await self.coordinator.client.restart()
```
## Device Classes
| Device Class | Icon | Use Case |
|--------------|------|----------|
| `IDENTIFY` | mdi:crosshairs-question | Flash light/beep to locate device |
| `RESTART` | mdi:restart | Restart the device |
| `UPDATE` | mdi:package-up | Trigger firmware update |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.button import ButtonDeviceClass, ButtonEntityDescription
@dataclass(frozen=True, kw_only=True)
class MyButtonEntityDescription(ButtonEntityDescription):
"""Describe My button entity."""
press_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
BUTTONS: tuple[MyButtonEntityDescription, ...] = (
MyButtonEntityDescription(
key="restart",
translation_key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.restart(),
),
MyButtonEntityDescription(
key="identify",
translation_key="identify",
device_class=ButtonDeviceClass.IDENTIFY,
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.identify(),
),
MyButtonEntityDescription(
key="factory_reset",
translation_key="factory_reset",
entity_category=EntityCategory.CONFIG,
press_fn=lambda client: client.factory_reset(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up buttons from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MyButton(coordinator, description)
for description in BUTTONS
)
class MyButton(MyEntity, ButtonEntity):
"""Button using entity description."""
entity_description: MyButtonEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyButtonEntityDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self.coordinator.client)
```
## Identify Button
```python
class IdentifyButton(MyEntity, ButtonEntity):
"""Identify button to locate the device."""
_attr_device_class = ButtonDeviceClass.IDENTIFY
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "identify"
async def async_press(self) -> None:
"""Flash the device LED to identify it."""
await self.coordinator.client.identify()
```
## Error Handling
```python
from homeassistant.exceptions import HomeAssistantError
class SafeButton(MyEntity, ButtonEntity):
"""Button with error handling."""
async def async_press(self) -> None:
"""Handle the button press with error handling."""
try:
await self.coordinator.client.perform_action()
except MyDeviceError as err:
raise HomeAssistantError(f"Failed to perform action: {err}") from err
```
## Confirmation Buttons
For dangerous operations, consider using a diagnostic category and clear naming:
```python
class FactoryResetButton(MyEntity, ButtonEntity):
"""Factory reset button."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_translation_key = "factory_reset"
_attr_entity_registry_enabled_default = False # Disabled by default
async def async_press(self) -> None:
"""Perform factory reset."""
await self.coordinator.client.factory_reset()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"button": {
"restart": {
"name": "Restart"
},
"identify": {
"name": "Identify"
},
"factory_reset": {
"name": "Factory reset"
}
}
}
}
```

View File

@@ -0,0 +1,254 @@
# Config Flow Reference
Configuration flows allow users to set up integrations via the UI.
## Basic Config Flow
```python
"""Config flow for My Integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_API_KEY
from homeassistant.helpers.selector import TextSelector
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): TextSelector(),
vol.Required(CONF_API_KEY): TextSelector(),
}
)
class MyIntegrationConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for My Integration."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
# Test connection
client = MyClient(user_input[CONF_HOST], user_input[CONF_API_KEY])
info = await client.get_device_info()
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Set unique ID and abort if already configured
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=info.name,
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
```
## Version Control
Always set version numbers:
```python
VERSION = 1 # Bump for breaking changes requiring migration
MINOR_VERSION = 1 # Bump for backward-compatible changes
```
## Unique ID Management
```python
# Set unique ID and abort if exists
await self.async_set_unique_id(device_serial)
self._abort_if_unique_id_configured()
# Or abort if data matches (when no unique ID available)
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
```
## Reauthentication Flow
```python
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauthentication confirmation."""
errors: dict[str, str] = {}
if user_input is not None:
try:
client = MyClient(
self._get_reauth_entry().data[CONF_HOST],
user_input[CONF_API_KEY]
)
info = await client.get_device_info()
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
```
## Reconfiguration Flow
```python
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
try:
client = MyClient(user_input[CONF_HOST], reconfigure_entry.data[CONF_API_KEY])
info = await client.get_device_info()
except CannotConnect:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(info.serial_number)
self._abort_if_unique_id_mismatch(reason="wrong_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: user_input[CONF_HOST]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({
vol.Required(CONF_HOST, default=reconfigure_entry.data[CONF_HOST]): str
}),
errors=errors,
)
```
## Discovery Flows
### Zeroconf Discovery
```python
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
serial = discovery_info.properties.get("serialno")
if not serial:
return self.async_abort(reason="no_serial")
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(
updates={CONF_HOST: str(discovery_info.host)}
)
self._discovered_host = str(discovery_info.host)
self._discovered_name = discovery_info.name.removesuffix("._mydevice._tcp.local.")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered_name,
data={CONF_HOST: self._discovered_host},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"name": self._discovered_name},
)
```
## strings.json for Config Flow
```json
{
"config": {
"step": {
"user": {
"title": "Connect to device",
"description": "Enter your device credentials.",
"data": {
"host": "Host",
"api_key": "API key"
}
},
"reauth_confirm": {
"title": "Reauthenticate",
"description": "Please enter a new API key for {name}.",
"data": {
"api_key": "API key"
}
},
"discovery_confirm": {
"title": "Discovered device",
"description": "Do you want to set up {name}?"
}
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured",
"wrong_account": "Wrong account",
"wrong_device": "Wrong device"
}
}
}
```
## Key Rules
1. **Never allow user-configurable entry names** (except helper integrations)
2. **Always test connection** before creating entry
3. **Always set unique ID** when possible
4. **Handle all exceptions** - bare `except Exception:` is allowed in config flows
5. **100% test coverage required** for all flow paths

View File

@@ -0,0 +1,239 @@
# Data Update Coordinator Reference
The coordinator pattern centralizes data fetching and provides efficient polling.
## Basic Coordinator
```python
"""DataUpdateCoordinator for My Integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from my_library import MyClient, MyData, MyError, AuthError
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import MyIntegrationConfigEntry
_LOGGER = logging.getLogger(__name__)
class MyCoordinator(DataUpdateCoordinator[MyData]):
"""My integration data update coordinator."""
config_entry: MyIntegrationConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
client: MyClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self.client = client
async def _async_update_data(self) -> MyData:
"""Fetch data from API."""
try:
return await self.client.get_data()
except AuthError as err:
raise ConfigEntryAuthFailed("Invalid credentials") from err
except MyError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
```
## Key Points
### Always Pass config_entry
```python
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry, # Always include this
)
```
### Generic Type Parameter
Specify the data type returned by `_async_update_data`:
```python
class MyCoordinator(DataUpdateCoordinator[MyData]):
...
```
### Error Types
- **`UpdateFailed`**: API communication errors (will retry)
- **`ConfigEntryAuthFailed`**: Authentication issues (triggers reauth flow)
## Polling Intervals
**Integration determines intervals** - never make them user-configurable.
```python
# Constants (in const.py)
SCAN_INTERVAL_LOCAL = timedelta(seconds=30)
SCAN_INTERVAL_CLOUD = timedelta(minutes=5)
# In coordinator
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, entry: MyIntegrationConfigEntry, client: MyClient) -> None:
# Determine interval based on connection type
interval = SCAN_INTERVAL_LOCAL if client.is_local else SCAN_INTERVAL_CLOUD
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=entry,
)
```
**Minimum intervals:**
- Local network: 5 seconds
- Cloud services: 60 seconds
## Coordinator with Device Info
```python
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
class MyCoordinator(DataUpdateCoordinator[MyData]):
"""Coordinator with device information."""
config_entry: MyIntegrationConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
client: MyClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self.client = client
self.device_info = DeviceInfo(
identifiers={(DOMAIN, client.serial_number)},
name=client.name,
manufacturer="My Company",
model=client.model,
sw_version=client.firmware_version,
)
async def _async_update_data(self) -> MyData:
"""Fetch data from API."""
try:
return await self.client.get_data()
except MyError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
```
## Multiple Data Sources
```python
from dataclasses import dataclass
@dataclass
class MyCoordinatorData:
"""Data class for coordinator."""
sensors: dict[str, SensorData]
status: DeviceStatus
settings: DeviceSettings
class MyCoordinator(DataUpdateCoordinator[MyCoordinatorData]):
"""Coordinator for multiple data sources."""
async def _async_update_data(self) -> MyCoordinatorData:
"""Fetch all data sources."""
try:
# Fetch all data concurrently
sensors, status, settings = await asyncio.gather(
self.client.get_sensors(),
self.client.get_status(),
self.client.get_settings(),
)
except MyError as err:
raise UpdateFailed(f"Error fetching data: {err}") from err
return MyCoordinatorData(
sensors=sensors,
status=status,
settings=settings,
)
```
## Setup in __init__.py
```python
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Set up My Integration from a config entry."""
client = MyClient(entry.data[CONF_HOST], entry.data[CONF_API_KEY])
coordinator = MyCoordinator(hass, entry, client)
# Perform first refresh - raises ConfigEntryNotReady on failure
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
```
## Testing Coordinators
```python
@pytest.fixture
def mock_coordinator(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> MyCoordinator:
"""Return a mocked coordinator."""
coordinator = MyCoordinator(hass, mock_config_entry, MagicMock())
coordinator.data = MyData(temperature=21.5, humidity=45)
return coordinator
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_client: MagicMock,
) -> None:
"""Test coordinator handles update failure."""
mock_client.get_data.side_effect = MyError("Connection failed")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()
```

View File

@@ -0,0 +1,248 @@
# Device Management Reference
Device management groups entities and provides device information.
## Device Info
```python
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Base entity with device info."""
_attr_has_entity_name = True
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.client.serial_number)},
name=coordinator.client.name,
manufacturer="My Company",
model=coordinator.client.model,
sw_version=coordinator.client.firmware_version,
hw_version=coordinator.client.hardware_version,
)
```
## DeviceInfo Fields
| Field | Description | Example |
|-------|-------------|---------|
| `identifiers` | Set of (domain, id) tuples | `{(DOMAIN, "ABC123")}` |
| `connections` | Set of (type, id) tuples | `{(CONNECTION_NETWORK_MAC, mac)}` |
| `name` | Device name | `"Living Room Thermostat"` |
| `manufacturer` | Manufacturer name | `"My Company"` |
| `model` | Model name | `"Smart Thermostat v2"` |
| `model_id` | Model identifier | `"THM-2000"` |
| `sw_version` | Software/firmware version | `"1.2.3"` |
| `hw_version` | Hardware version | `"rev2"` |
| `serial_number` | Serial number | `"ABC123456"` |
| `configuration_url` | Device config URL | `"http://192.168.1.100"` |
| `suggested_area` | Suggested room/area | `"Living Room"` |
| `entry_type` | Device entry type | `DeviceEntryType.SERVICE` |
| `via_device` | Parent device identifiers | `(DOMAIN, "hub_id")` |
## Device with Connections
Use connections (like MAC address) for better device merging:
```python
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(coordinator.client.mac))},
identifiers={(DOMAIN, coordinator.client.serial_number)},
name=coordinator.client.name,
manufacturer="My Company",
model=coordinator.client.model,
)
```
## Hub and Child Devices
```python
# Hub device
class HubEntity(CoordinatorEntity[MyCoordinator]):
"""Hub entity."""
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the hub entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.hub_id)},
name="My Hub",
manufacturer="My Company",
model="Hub Pro",
)
# Child device connected via hub
class ChildEntity(CoordinatorEntity[MyCoordinator]):
"""Child device entity."""
def __init__(self, coordinator: MyCoordinator, device: ChildDevice) -> None:
"""Initialize the child entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
manufacturer="My Company",
model=device.model,
via_device=(DOMAIN, coordinator.hub_id), # Links to parent hub
)
```
## Service Entry Type
For cloud services without physical devices:
```python
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="My Cloud Service",
manufacturer="My Company",
entry_type=DeviceEntryType.SERVICE,
)
```
## Dynamic Device Addition
Auto-detect new devices after initial setup:
```python
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
known_devices: set[str] = set()
@callback
def _check_devices() -> None:
"""Check for new devices."""
current_devices = set(coordinator.data.devices.keys())
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
MySensor(coordinator, device_id)
for device_id in new_devices
)
# Initial setup
_check_devices()
# Listen for updates
entry.async_on_unload(coordinator.async_add_listener(_check_devices))
```
## Stale Device Removal
Remove devices when they disappear:
```python
async def _async_update_data(self) -> MyData:
"""Fetch data and handle device removal."""
data = await self.client.get_data()
# Check for removed devices
device_registry = dr.async_get(self.hass)
current_device_ids = set(data.devices.keys())
for device_entry in dr.async_entries_for_config_entry(
device_registry, self.config_entry.entry_id
):
# Get device ID from identifiers
device_id = next(
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
None,
)
if device_id and device_id not in current_device_ids:
# Device no longer exists, remove it
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
return data
```
## Manual Device Removal
Allow users to manually remove devices:
```python
# In __init__.py
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: MyIntegrationConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
# Get device ID from identifiers
device_id = next(
(id for domain, id in device_entry.identifiers if domain == DOMAIN),
None,
)
if device_id is None:
return False
# Check if device is still present (don't allow removal of active devices)
coordinator = config_entry.runtime_data
if device_id in coordinator.data.devices:
return False # Device still exists, can't remove
return True # Allow removal of stale device
```
## Device Registry Access
```python
from homeassistant.helpers import device_registry as dr
# Get device registry
device_registry = dr.async_get(hass)
# Get device by identifiers
device = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
# Get all devices for config entry
devices = dr.async_entries_for_config_entry(
device_registry, entry.entry_id
)
# Update device
device_registry.async_update_device(
device.id,
sw_version="2.0.0",
)
```
## Quality Scale Requirements
- **Bronze**: No specific device requirements
- **Gold**: Devices rule - group entities under devices
- **Gold**: Stale device removal - auto-remove disconnected devices
- **Gold**: Dynamic device addition - detect new devices at runtime

View File

@@ -0,0 +1,278 @@
# Diagnostics Reference
Diagnostics provide debug information for troubleshooting integrations.
## Basic Diagnostics
```python
"""Diagnostics support for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
from . import MyIntegrationConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_PASSWORD,
CONF_TOKEN,
"serial_number",
"mac_address",
"latitude",
"longitude",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
"entry_options": async_redact_data(dict(entry.options), TO_REDACT),
"coordinator_data": async_redact_data(
coordinator.data.to_dict(), TO_REDACT
),
}
```
## What to Include
**Do include:**
- Configuration data (redacted)
- Current coordinator data
- Device information
- Error states and counts
- Connection status
- Firmware versions
- Feature flags
**Never include (always redact):**
- API keys, tokens, passwords
- Geographic coordinates (latitude/longitude)
- Personal identifiable information
- Email addresses
- MAC addresses (unless needed for debugging)
- Serial numbers (unless needed for debugging)
## Comprehensive Diagnostics
```python
"""Diagnostics support for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import MyIntegrationConfigEntry
from .const import DOMAIN
TO_REDACT = {
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
"serial",
"serial_number",
"mac",
"mac_address",
"email",
"access_token",
"refresh_token",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
# Get device registry entries
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
devices = []
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
entities = []
for entity in er.async_entries_for_device(
entity_registry, device.id, include_disabled_entities=True
):
entities.append({
"entity_id": entity.entity_id,
"unique_id": entity.unique_id,
"platform": entity.platform,
"disabled": entity.disabled,
"disabled_by": entity.disabled_by,
})
devices.append({
"name": device.name,
"model": device.model,
"manufacturer": device.manufacturer,
"sw_version": device.sw_version,
"hw_version": device.hw_version,
"identifiers": list(device.identifiers),
"connections": list(device.connections),
"entities": entities,
})
return {
"entry": {
"version": entry.version,
"minor_version": entry.minor_version,
"data": async_redact_data(dict(entry.data), TO_REDACT),
"options": async_redact_data(dict(entry.options), TO_REDACT),
},
"coordinator": {
"last_update_success": coordinator.last_update_success,
"last_exception": str(coordinator.last_exception) if coordinator.last_exception else None,
"data": async_redact_data(coordinator.data.to_dict(), TO_REDACT),
},
"devices": devices,
}
```
## Device-Level Diagnostics
For integrations with multiple devices, you can also provide device-level diagnostics:
```python
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: MyIntegrationConfigEntry, device: dr.DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
# Find device data based on device identifiers
device_id = next(
(id for domain, id in device.identifiers if domain == DOMAIN), None
)
if device_id is None:
return {"error": "Device not found"}
device_data = coordinator.data.devices.get(device_id)
if device_data is None:
return {"error": "Device data not found"}
return {
"device_info": {
"name": device.name,
"model": device.model,
"sw_version": device.sw_version,
},
"device_data": async_redact_data(device_data.to_dict(), TO_REDACT),
}
```
## Redaction Patterns
### Simple Redaction
```python
from homeassistant.components.diagnostics import async_redact_data
data = {"api_key": "secret123", "temperature": 21.5}
redacted = async_redact_data(data, {"api_key"})
# Result: {"api_key": "**REDACTED**", "temperature": 21.5}
```
### Nested Redaction
`async_redact_data` handles nested dictionaries automatically:
```python
data = {
"config": {
"host": "192.168.1.1",
"api_key": "secret123",
},
"device": {
"name": "My Device",
"serial_number": "ABC123",
}
}
redacted = async_redact_data(data, {"api_key", "serial_number"})
# Result: {"config": {"host": "192.168.1.1", "api_key": "**REDACTED**"},
# "device": {"name": "My Device", "serial_number": "**REDACTED**"}}
```
### Custom Redaction
For complex redaction needs:
```python
def _redact_data(data: dict[str, Any]) -> dict[str, Any]:
"""Redact sensitive data."""
result = dict(data)
# Redact specific keys
for key in ("api_key", "token", "password"):
if key in result:
result[key] = "**REDACTED**"
# Redact partial data (e.g., keep last 4 chars)
if "serial" in result:
result["serial"] = f"****{result['serial'][-4:]}"
# Redact coordinates to city level
if "latitude" in result:
result["latitude"] = round(result["latitude"], 1)
if "longitude" in result:
result["longitude"] = round(result["longitude"], 1)
return result
```
## Testing Diagnostics
```python
from homeassistant.components.diagnostics import REDACTED
from custom_components.my_integration.diagnostics import (
async_get_config_entry_diagnostics,
)
async def test_diagnostics(
hass: HomeAssistant,
init_integration: MockConfigEntry,
) -> None:
"""Test diagnostics."""
diagnostics = await async_get_config_entry_diagnostics(hass, init_integration)
assert diagnostics["entry"]["data"]["host"] == "192.168.1.1"
assert diagnostics["entry"]["data"]["api_key"] == REDACTED
assert "temperature" in diagnostics["coordinator"]["data"]
```
## Quality Scale Requirement
Diagnostics are required for **Gold** quality scale and above. Ensure your `quality_scale.yaml` includes:
```yaml
rules:
diagnostics: done
```

View File

@@ -0,0 +1,286 @@
# Entity Development Reference
Base patterns for entity development in Home Assistant.
## Base Entity Class
Create a shared base class to reduce duplication:
```python
"""Base entity for My Integration."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import MyCoordinator
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Base entity for My Integration."""
_attr_has_entity_name = True
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
```
## Unique IDs
Every entity must have a unique ID:
```python
class MySensor(MyEntity, SensorEntity):
"""Sensor entity."""
def __init__(self, coordinator: MyCoordinator, sensor_type: str) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
# Unique per platform, don't include domain or platform name
self._attr_unique_id = f"{coordinator.client.serial_number}_{sensor_type}"
```
**Acceptable unique ID sources:**
- Device serial numbers
- MAC addresses (use `format_mac` from device registry)
- Physical identifiers
**Never use:**
- IP addresses, hostnames, URLs
- Device names
- Email addresses, usernames
## Entity Naming
```python
class MySensor(MyEntity, SensorEntity):
"""Sensor with proper naming."""
_attr_has_entity_name = True
_attr_translation_key = "temperature" # Translatable name
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
# For the main/primary entity of a device, use None
# self._attr_name = None
# For secondary entities, set the name
self._attr_name = "Temperature" # Or use translation_key
```
## Entity Translations
In `strings.json`:
```json
{
"entity": {
"sensor": {
"temperature": {
"name": "Temperature"
},
"humidity": {
"name": "Humidity"
},
"battery": {
"name": "Battery",
"state": {
"charging": "Charging",
"discharging": "Discharging"
}
}
}
}
}
```
## Entity Availability
### Coordinator Pattern
```python
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._sensor_key in self.coordinator.data.sensors
```
### Direct Update Pattern
```python
async def async_update(self) -> None:
"""Update entity state."""
try:
data = await self.client.get_data()
except MyException:
self._attr_available = False
return
self._attr_available = True
self._attr_native_value = data.value
```
## Entity Categories
```python
from homeassistant.const import EntityCategory
class DiagnosticSensor(MyEntity, SensorEntity):
"""Diagnostic sensor (hidden by default in UI)."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
class ConfigSwitch(MyEntity, SwitchEntity):
"""Configuration switch."""
_attr_entity_category = EntityCategory.CONFIG
```
## Disabled by Default
For noisy or less popular entities:
```python
class SignalStrengthSensor(MyEntity, SensorEntity):
"""Signal strength sensor - disabled by default."""
_attr_entity_registry_enabled_default = False
```
## Event Lifecycle
```python
class MyEntity(CoordinatorEntity[MyCoordinator]):
"""Entity with event subscriptions."""
async def async_added_to_hass(self) -> None:
"""Subscribe to events when added."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.client.events.subscribe(
"state_changed",
self._handle_state_change,
)
)
@callback
def _handle_state_change(self, event: Event) -> None:
"""Handle state change event."""
self._attr_native_value = event.value
self.async_write_ha_state()
```
**Key rules:**
- Subscribe in `async_added_to_hass`
- Use `async_on_remove` for automatic cleanup
- Never subscribe in `__init__`
## State Handling
```python
@property
def native_value(self) -> StateType:
"""Return the state."""
value = self.coordinator.data.get(self._key)
# Use None for unknown values, never "unknown" or "unavailable" strings
if value is None:
return None
return value
```
## Extra State Attributes
```python
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes."""
data = self.coordinator.data
# All keys must always be present, use None for unknown
return {
"last_updated": data.last_updated,
"error_count": data.error_count,
"firmware": data.firmware or None, # Never omit keys
}
```
## Entity Descriptions Pattern
For multiple similar entities:
```python
from dataclasses import dataclass
from collections.abc import Callable
from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySensorEntityDescription(SensorEntityDescription):
"""Describe My sensor entity."""
value_fn: Callable[[MyData], StateType]
SENSOR_DESCRIPTIONS: tuple[MySensorEntityDescription, ...] = (
MySensorEntityDescription(
key="temperature",
translation_key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
MySensorEntityDescription(
key="humidity",
translation_key="humidity",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.humidity,
),
)
class MySensor(MyEntity, SensorEntity):
"""Sensor using entity description."""
entity_description: MySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
```
## Multiline Lambdas
When lambdas are too long:
```python
# Good pattern - parentheses on same line as lambda
MySensorEntityDescription(
key="temperature",
value_fn=lambda data: (
round(data["temp_value"] * 1.8 + 32, 1)
if data.get("temp_value") is not None
else None
),
)
```

View File

@@ -0,0 +1,229 @@
# Number Platform Reference
Number entities represent numeric values that can be set.
## Basic Number
```python
"""Number platform for My Integration."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up numbers from config entry."""
coordinator = entry.runtime_data
async_add_entities([
TargetTemperatureNumber(coordinator),
])
class TargetTemperatureNumber(MyEntity, NumberEntity):
"""Target temperature number entity."""
_attr_native_min_value = 16
_attr_native_max_value = 30
_attr_native_step = 0.5
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_mode = NumberMode.SLIDER
_attr_translation_key = "target_temperature"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_target_temp"
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.coordinator.data.target_temperature
async def async_set_native_value(self, value: float) -> None:
"""Set the target temperature."""
await self.coordinator.client.set_target_temperature(value)
await self.coordinator.async_request_refresh()
```
## Number Modes
```python
from homeassistant.components.number import NumberMode
# Slider display in UI
_attr_mode = NumberMode.SLIDER
# Input box display in UI
_attr_mode = NumberMode.BOX
# Auto (slider if range <= 256, else box)
_attr_mode = NumberMode.AUTO
```
## Device Classes
```python
from homeassistant.components.number import NumberDeviceClass
# For temperature settings
_attr_device_class = NumberDeviceClass.TEMPERATURE
# Other device classes
NumberDeviceClass.HUMIDITY
NumberDeviceClass.POWER
NumberDeviceClass.VOLTAGE
NumberDeviceClass.CURRENT
```
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.number import NumberEntityDescription, NumberMode
@dataclass(frozen=True, kw_only=True)
class MyNumberEntityDescription(NumberEntityDescription):
"""Describe My number entity."""
value_fn: Callable[[MyData], float | None]
set_value_fn: Callable[[MyClient, float], Coroutine[Any, Any, None]]
NUMBERS: tuple[MyNumberEntityDescription, ...] = (
MyNumberEntityDescription(
key="target_temperature",
translation_key="target_temperature",
native_min_value=16,
native_max_value=30,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
mode=NumberMode.SLIDER,
value_fn=lambda data: data.target_temperature,
set_value_fn=lambda client, value: client.set_target_temperature(value),
),
MyNumberEntityDescription(
key="brightness",
translation_key="brightness",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG,
value_fn=lambda data: data.brightness,
set_value_fn=lambda client, value: client.set_brightness(int(value)),
),
)
class MyNumber(MyEntity, NumberEntity):
"""Number using entity description."""
entity_description: MyNumberEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MyNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> float | None:
"""Return the current value."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
await self.entity_description.set_value_fn(self.coordinator.client, value)
await self.coordinator.async_request_refresh()
```
## Dynamic Min/Max Values
```python
class DynamicRangeNumber(MyEntity, NumberEntity):
"""Number with dynamic range based on device capabilities."""
_attr_translation_key = "fan_speed"
@property
def native_min_value(self) -> float:
"""Return minimum value."""
return self.coordinator.data.fan_speed_min
@property
def native_max_value(self) -> float:
"""Return maximum value."""
return self.coordinator.data.fan_speed_max
@property
def native_step(self) -> float:
"""Return step value."""
return self.coordinator.data.fan_speed_step or 1
```
## Configuration Number
```python
class ConfigNumber(MyEntity, NumberEntity):
"""Configuration number entity."""
_attr_entity_category = EntityCategory.CONFIG
_attr_native_min_value = 1
_attr_native_max_value = 60
_attr_native_step = 1
_attr_native_unit_of_measurement = "min"
_attr_translation_key = "timeout"
@property
def native_value(self) -> float | None:
"""Return the timeout setting."""
return self.coordinator.data.timeout_minutes
async def async_set_native_value(self, value: float) -> None:
"""Set the timeout."""
await self.coordinator.client.set_timeout(int(value))
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"number": {
"target_temperature": {
"name": "Target temperature"
},
"brightness": {
"name": "Brightness"
},
"timeout": {
"name": "Timeout"
}
}
}
}
```

View File

@@ -0,0 +1,252 @@
# Select Platform Reference
Select entities allow choosing from a predefined list of options.
## Basic Select
```python
"""Select platform for My Integration."""
from __future__ import annotations
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up selects from config entry."""
coordinator = entry.runtime_data
async_add_entities([
ModeSelect(coordinator),
])
class ModeSelect(MyEntity, SelectEntity):
"""Mode select entity."""
_attr_options = ["auto", "cool", "heat", "fan_only", "dry"]
_attr_translation_key = "mode"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_mode"
@property
def current_option(self) -> str | None:
"""Return the current option."""
return self.coordinator.data.mode
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.coordinator.client.set_mode(option)
await self.coordinator.async_request_refresh()
```
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.select import SelectEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySelectEntityDescription(SelectEntityDescription):
"""Describe My select entity."""
current_option_fn: Callable[[MyData], str | None]
select_option_fn: Callable[[MyClient, str], Coroutine[Any, Any, None]]
SELECTS: tuple[MySelectEntityDescription, ...] = (
MySelectEntityDescription(
key="mode",
translation_key="mode",
options=["auto", "cool", "heat", "fan_only", "dry"],
current_option_fn=lambda data: data.mode,
select_option_fn=lambda client, option: client.set_mode(option),
),
MySelectEntityDescription(
key="fan_speed",
translation_key="fan_speed",
options=["low", "medium", "high", "auto"],
entity_category=EntityCategory.CONFIG,
current_option_fn=lambda data: data.fan_speed,
select_option_fn=lambda client, option: client.set_fan_speed(option),
),
)
class MySelect(MyEntity, SelectEntity):
"""Select using entity description."""
entity_description: MySelectEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySelectEntityDescription,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def options(self) -> list[str]:
"""Return available options."""
return list(self.entity_description.options)
@property
def current_option(self) -> str | None:
"""Return current option."""
return self.entity_description.current_option_fn(self.coordinator.data)
async def async_select_option(self, option: str) -> None:
"""Select an option."""
await self.entity_description.select_option_fn(self.coordinator.client, option)
await self.coordinator.async_request_refresh()
```
## Dynamic Options
```python
class DynamicSelect(MyEntity, SelectEntity):
"""Select with options from device."""
_attr_translation_key = "preset"
@property
def options(self) -> list[str]:
"""Return available presets from device."""
return self.coordinator.data.available_presets
@property
def current_option(self) -> str | None:
"""Return current preset."""
return self.coordinator.data.current_preset
async def async_select_option(self, option: str) -> None:
"""Select a preset."""
await self.coordinator.client.set_preset(option)
await self.coordinator.async_request_refresh()
```
## Configuration Select
```python
class ConfigSelect(MyEntity, SelectEntity):
"""Configuration select (settings)."""
_attr_entity_category = EntityCategory.CONFIG
_attr_options = ["silent", "normal", "boost"]
_attr_translation_key = "performance_mode"
@property
def current_option(self) -> str | None:
"""Return current performance mode."""
return self.coordinator.data.performance_mode
async def async_select_option(self, option: str) -> None:
"""Set performance mode."""
await self.coordinator.client.set_performance_mode(option)
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"select": {
"mode": {
"name": "Mode",
"state": {
"auto": "Auto",
"cool": "Cool",
"heat": "Heat",
"fan_only": "Fan only",
"dry": "Dry"
}
},
"fan_speed": {
"name": "Fan speed",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High",
"auto": "Auto"
}
},
"performance_mode": {
"name": "Performance mode",
"state": {
"silent": "Silent",
"normal": "Normal",
"boost": "Boost"
}
}
}
}
}
```
## Icon by State
In `strings.json`:
```json
{
"entity": {
"select": {
"mode": {
"name": "Mode",
"default": "mdi:thermostat",
"state": {
"auto": "Auto",
"cool": "Cool",
"heat": "Heat"
},
"state_icons": {
"auto": "mdi:thermostat-auto",
"cool": "mdi:snowflake",
"heat": "mdi:fire"
}
}
}
}
}
```
Note: State icons are defined in `icons.json`:
```json
{
"entity": {
"select": {
"mode": {
"default": "mdi:thermostat",
"state": {
"auto": "mdi:thermostat-auto",
"cool": "mdi:snowflake",
"heat": "mdi:fire"
}
}
}
}
}
```

View File

@@ -0,0 +1,271 @@
# Sensor Platform Reference
Sensors represent read-only values from devices.
## Basic Sensor
```python
"""Sensor platform for My Integration."""
from __future__ import annotations
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities([
TemperatureSensor(coordinator),
HumiditySensor(coordinator),
])
class TemperatureSensor(MyEntity, SensorEntity):
"""Temperature sensor."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "temperature"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_temperature"
@property
def native_value(self) -> float | None:
"""Return the temperature."""
return self.coordinator.data.temperature
```
## Device Classes
Common sensor device classes:
| Device Class | Unit Examples | Use Case |
|--------------|---------------|----------|
| `TEMPERATURE` | °C, °F | Temperature readings |
| `HUMIDITY` | % | Humidity levels |
| `PRESSURE` | hPa, mbar | Atmospheric pressure |
| `BATTERY` | % | Battery level |
| `POWER` | W, kW | Power consumption |
| `ENERGY` | Wh, kWh | Energy usage |
| `VOLTAGE` | V | Electrical voltage |
| `CURRENT` | A, mA | Electrical current |
| `CO2` | ppm | Carbon dioxide |
| `PM25` | µg/m³ | Particulate matter |
## State Classes
```python
from homeassistant.components.sensor import SensorStateClass
# For instantaneous values that can go up or down
_attr_state_class = SensorStateClass.MEASUREMENT
# For ever-increasing totals (like energy consumption)
_attr_state_class = SensorStateClass.TOTAL
# For totals that reset periodically
_attr_state_class = SensorStateClass.TOTAL_INCREASING
```
## Entity Description Pattern
For multiple sensors with similar structure:
```python
from dataclasses import dataclass
from collections.abc import Callable
from typing import Any
from homeassistant.components.sensor import SensorEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySensorEntityDescription(SensorEntityDescription):
"""Describe My sensor entity."""
value_fn: Callable[[MyData], Any]
SENSORS: tuple[MySensorEntityDescription, ...] = (
MySensorEntityDescription(
key="temperature",
translation_key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.temperature,
),
MySensorEntityDescription(
key="humidity",
translation_key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.humidity,
),
MySensorEntityDescription(
key="signal_strength",
translation_key="signal_strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, # Disabled by default
value_fn=lambda data: data.rssi,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up sensors from config entry."""
coordinator = entry.runtime_data
async_add_entities(
MySensor(coordinator, description)
for description in SENSORS
)
class MySensor(MyEntity, SensorEntity):
"""Sensor using entity description."""
entity_description: MySensorEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
```
## Suggested Display Precision
```python
# Control decimal places shown in UI
_attr_suggested_display_precision = 1 # Show 21.5 instead of 21.456789
```
## Timestamp Sensors
```python
from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass
class LastUpdatedSensor(MyEntity, SensorEntity):
"""Last updated timestamp sensor."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_translation_key = "last_updated"
@property
def native_value(self) -> datetime | None:
"""Return the last update timestamp."""
return self.coordinator.data.last_updated
```
## Enum Sensors
```python
from homeassistant.components.sensor import SensorDeviceClass
class StatusSensor(MyEntity, SensorEntity):
"""Status sensor with enum values."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["idle", "running", "error", "offline"]
_attr_translation_key = "status"
@property
def native_value(self) -> str | None:
"""Return the current status."""
return self.coordinator.data.status
```
With translations in `strings.json`:
```json
{
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"idle": "Idle",
"running": "Running",
"error": "Error",
"offline": "Offline"
}
}
}
}
}
```
## Dynamic Icons
In `strings.json`:
```json
{
"entity": {
"sensor": {
"battery_level": {
"name": "Battery level",
"default": "mdi:battery-unknown",
"range": {
"0": "mdi:battery-outline",
"10": "mdi:battery-10",
"50": "mdi:battery-50",
"90": "mdi:battery-90",
"100": "mdi:battery"
}
}
}
}
}
```
## PARALLEL_UPDATES
```python
# At module level - limit concurrent updates
PARALLEL_UPDATES = 1 # Serialize to prevent overwhelming device
# Or unlimited for coordinator-based platforms
PARALLEL_UPDATES = 0
```

View File

@@ -0,0 +1,335 @@
# Services Reference
Services allow automations and users to trigger actions.
## Service Registration
Register services in `async_setup`, NOT in `async_setup_entry`:
```python
"""My Integration setup."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN
SERVICE_REFRESH = "refresh"
SERVICE_SET_SCHEDULE = "set_schedule"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up My Integration services."""
async def handle_refresh(call: ServiceCall) -> None:
"""Handle refresh service call."""
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
coordinator = entry.runtime_data
await coordinator.async_request_refresh()
hass.services.async_register(
DOMAIN,
SERVICE_REFRESH,
handle_refresh,
schema=vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}),
)
return True
```
## Service with Response
```python
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up services with response."""
async def handle_get_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle get_schedule service call."""
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
if not (entry := hass.config_entries.async_get_entry(entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
coordinator = entry.runtime_data
schedule = await coordinator.client.get_schedule()
return {
"schedule": [
{"day": item.day, "start": item.start, "end": item.end}
for item in schedule
]
}
hass.services.async_register(
DOMAIN,
"get_schedule",
handle_get_schedule,
schema=vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
}),
supports_response=SupportsResponse.ONLY, # or SupportsResponse.OPTIONAL
)
return True
```
## Entity Services
Register entity-specific services in platform setup:
```python
"""Switch platform with entity service."""
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import voluptuous as vol
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches from config entry."""
coordinator = entry.runtime_data
async_add_entities([PowerSwitch(coordinator)])
# Register entity service
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
"set_timer",
{
vol.Required("minutes"): vol.All(
vol.Coerce(int), vol.Range(min=1, max=120)
),
},
"async_set_timer",
)
class PowerSwitch(MyEntity, SwitchEntity):
"""Power switch with timer service."""
async def async_set_timer(self, minutes: int) -> None:
"""Set auto-off timer."""
await self.coordinator.client.set_timer(minutes)
```
## Service Validation
```python
from homeassistant.exceptions import ServiceValidationError
async def handle_set_schedule(call: ServiceCall) -> None:
"""Handle set_schedule service call."""
start_date = call.data["start_date"]
end_date = call.data["end_date"]
# Validate input
if end_date < start_date:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_date_before_start_date",
)
entry_id = call.data[ATTR_CONFIG_ENTRY_ID]
entry = hass.config_entries.async_get_entry(entry_id)
try:
await entry.runtime_data.client.set_schedule(start_date, end_date)
except MyConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from err
```
## services.yaml
Define services in `services.yaml`:
```yaml
refresh:
name: Refresh
description: Force a data refresh from the device.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to refresh.
required: true
selector:
config_entry:
integration: my_integration
set_schedule:
name: Set schedule
description: Set the device schedule.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to configure.
required: true
selector:
config_entry:
integration: my_integration
start_date:
name: Start date
description: Schedule start date.
required: true
selector:
date:
end_date:
name: End date
description: Schedule end date.
required: true
selector:
date:
get_schedule:
name: Get schedule
description: Get the current device schedule.
fields:
config_entry_id:
name: Config entry ID
description: The config entry to query.
required: true
selector:
config_entry:
integration: my_integration
set_timer:
name: Set timer
description: Set auto-off timer for the switch.
target:
entity:
integration: my_integration
domain: switch
fields:
minutes:
name: Minutes
description: Timer duration in minutes.
required: true
selector:
number:
min: 1
max: 120
unit_of_measurement: min
```
## Exception Translations
In `strings.json`:
```json
{
"exceptions": {
"entry_not_found": {
"message": "Config entry not found."
},
"entry_not_loaded": {
"message": "Config entry is not loaded."
},
"end_date_before_start_date": {
"message": "The end date cannot be before the start date."
},
"connection_failed": {
"message": "Failed to connect to the device."
}
}
}
```
## Device-Based Service Targeting
```python
async def handle_device_service(call: ServiceCall) -> None:
"""Handle service call targeting a device."""
device_id = call.data[ATTR_DEVICE_ID]
device_registry = dr.async_get(hass)
device = device_registry.async_get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="device_not_found",
)
# Find config entry for device
entry_id = next(
(entry_id for entry_id in device.config_entries if entry_id),
None,
)
if entry_id is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_found",
)
entry = hass.config_entries.async_get_entry(entry_id)
# ... continue with service logic
```
## Service Schema Patterns
```python
import voluptuous as vol
from homeassistant.helpers import config_validation as cv
# Basic schema
SERVICE_SCHEMA = vol.Schema({
vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string,
vol.Required("value"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
vol.Optional("timeout", default=30): cv.positive_int,
})
# With entity targeting
SERVICE_SCHEMA_ENTITY = vol.Schema({
vol.Required("entity_id"): cv.entity_ids,
vol.Required("parameter"): cv.string,
})
# With selectors (for services.yaml)
# Use selector in services.yaml, not in Python schema
```
## Quality Scale Requirements
- **Bronze**: `action-setup` - Register services in `async_setup` if integration has services
- Services must validate config entry state before use
- Use translated exceptions for error messages

View File

@@ -0,0 +1,165 @@
---
name: ha-integration
description: Develop Home Assistant integrations following best practices. Use when creating, modifying, or reviewing integration code including config flows, entities, coordinators, diagnostics, services, and tests.
---
# Home Assistant Integration Development
You are developing a Home Assistant integration. Follow these guidelines and reference the supporting documentation for specific components.
## Quick Reference
| Component | Reference File |
|-----------|----------------|
| Config flow | [CONFIG-FLOW.MD](CONFIG-FLOW.MD) |
| Data coordinator | [COORDINATOR.MD](COORDINATOR.MD) |
| Entities (base) | [ENTITY.MD](ENTITY.MD) |
| Sensors | [SENSOR.MD](SENSOR.MD) |
| Binary sensors | [BINARY-SENSOR.MD](BINARY-SENSOR.MD) |
| Switches | [SWITCH.MD](SWITCH.MD) |
| Numbers | [NUMBER.MD](NUMBER.MD) |
| Selects | [SELECT.MD](SELECT.MD) |
| Buttons | [BUTTON.MD](BUTTON.MD) |
| Device management | [DEVICE.MD](DEVICE.MD) |
| Diagnostics | [DIAGNOSTICS.MD](DIAGNOSTICS.MD) |
| Services | [SERVICES.MD](SERVICES.MD) |
| Testing | [TESTING.MD](TESTING.MD) |
## Integration Structure
```
homeassistant/components/my_integration/
├── __init__.py # Entry point with async_setup_entry
├── manifest.json # Integration metadata and dependencies
├── const.py # Domain and constants
├── config_flow.py # UI configuration flow
├── coordinator.py # Data update coordinator
├── entity.py # Base entity class
├── sensor.py # Sensor platform
├── diagnostics.py # Diagnostic data collection
├── strings.json # User-facing text and translations
├── services.yaml # Service definitions (if applicable)
└── quality_scale.yaml # Quality scale rule status
```
## Quality Scale Levels
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
- **Silver**: Enhanced functionality (entity unavailability, parallel updates, auth flows)
- **Gold**: Advanced features (device management, diagnostics, translations)
- **Platinum**: Highest quality (strict typing, async dependencies, websession injection)
Check `manifest.json` for `"quality_scale"` key and `quality_scale.yaml` for rule status.
## Core Patterns
### Entry Point (`__init__.py`)
```python
"""Integration for My Device."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import MyCoordinator
PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR]
type MyIntegrationConfigEntry = ConfigEntry[MyCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Set up My Integration from a config entry."""
coordinator = MyCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
```
### Constants (`const.py`)
```python
"""Constants for My Integration."""
DOMAIN = "my_integration"
```
### Manifest (`manifest.json`)
```json
{
"domain": "my_integration",
"name": "My Integration",
"codeowners": ["@username"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/my_integration",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["my-library==1.0.0"],
"quality_scale": "bronze"
}
```
## Python Requirements
- **Compatibility**: Python 3.13+
- **Type hints**: Required for all functions and methods
- **f-strings**: Preferred over `%` or `.format()`
- **Async**: All external I/O must be async
## Code Quality
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Testing**: pytest with >95% coverage
## Common Anti-Patterns to Avoid
```python
# Blocking operations
data = requests.get(url) # Use async or executor
time.sleep(5) # Use asyncio.sleep()
# Hardcoded strings
self._attr_name = "Temperature" # Use translation_key
# Too much in try block
try:
data = await client.get_data()
processed = data["value"] * 100 # Move outside try
except Error:
pass
# User-configurable polling
vol.Optional("scan_interval"): cv.positive_int # Not allowed
```
## Development Commands
```bash
# Run tests with coverage
pytest ./tests/components/<domain> \
--cov=homeassistant.components.<domain> \
--cov-report term-missing \
--numprocesses=auto
# Type checking
mypy homeassistant/components/<domain>
# Linting
pylint homeassistant/components/<domain>
# Validate integration
python -m script.hassfest --integration-path homeassistant/components/<domain>
```

View File

@@ -0,0 +1,236 @@
# Switch Platform Reference
Switches control on/off functionality.
## Basic Switch
```python
"""Switch platform for My Integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchDeviceClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MyIntegrationConfigEntry
from .entity import MyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MyIntegrationConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up switches from config entry."""
coordinator = entry.runtime_data
async_add_entities([
PowerSwitch(coordinator),
])
class PowerSwitch(MyEntity, SwitchEntity):
"""Power switch."""
_attr_device_class = SwitchDeviceClass.SWITCH
_attr_translation_key = "power"
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.client.serial_number}_power"
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.coordinator.data.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.coordinator.client.turn_on()
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.coordinator.client.turn_off()
await self.coordinator.async_request_refresh()
```
## Device Classes
| Device Class | Use Case |
|--------------|----------|
| `OUTLET` | Electrical outlet |
| `SWITCH` | Generic switch |
## Entity Description Pattern
```python
from dataclasses import dataclass
from collections.abc import Callable, Coroutine
from typing import Any
from homeassistant.components.switch import SwitchEntityDescription
@dataclass(frozen=True, kw_only=True)
class MySwitchEntityDescription(SwitchEntityDescription):
"""Describe My switch entity."""
is_on_fn: Callable[[MyData], bool | None]
turn_on_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[MyClient], Coroutine[Any, Any, None]]
SWITCHES: tuple[MySwitchEntityDescription, ...] = (
MySwitchEntityDescription(
key="power",
translation_key="power",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.is_on,
turn_on_fn=lambda client: client.turn_on(),
turn_off_fn=lambda client: client.turn_off(),
),
MySwitchEntityDescription(
key="child_lock",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda data: data.child_lock_enabled,
turn_on_fn=lambda client: client.set_child_lock(True),
turn_off_fn=lambda client: client.set_child_lock(False),
),
)
class MySwitch(MyEntity, SwitchEntity):
"""Switch using entity description."""
entity_description: MySwitchEntityDescription
def __init__(
self,
coordinator: MyCoordinator,
description: MySwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.client.serial_number}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.entity_description.is_on_fn(self.coordinator.data)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.turn_on_fn(self.coordinator.client)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.turn_off_fn(self.coordinator.client)
await self.coordinator.async_request_refresh()
```
## Configuration Switch
```python
class ConfigSwitch(MyEntity, SwitchEntity):
"""Configuration switch (e.g., enable/disable a feature)."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "auto_mode"
@property
def is_on(self) -> bool | None:
"""Return true if auto mode is enabled."""
return self.coordinator.data.auto_mode_enabled
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable auto mode."""
await self.coordinator.client.set_auto_mode(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable auto mode."""
await self.coordinator.client.set_auto_mode(False)
await self.coordinator.async_request_refresh()
```
## Optimistic Updates
For devices with slow response:
```python
class OptimisticSwitch(MyEntity, SwitchEntity):
"""Switch with optimistic state updates."""
_attr_assumed_state = True # Indicates state may not be accurate
def __init__(self, coordinator: MyCoordinator) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self._optimistic_state: bool | None = None
@property
def is_on(self) -> bool | None:
"""Return optimistic state if set, otherwise coordinator state."""
if self._optimistic_state is not None:
return self._optimistic_state
return self.coordinator.data.is_on
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on with optimistic update."""
self._optimistic_state = True
self.async_write_ha_state()
try:
await self.coordinator.client.turn_on()
finally:
self._optimistic_state = None
await self.coordinator.async_request_refresh()
```
## Error Handling
```python
from homeassistant.exceptions import HomeAssistantError
class RobustSwitch(MyEntity, SwitchEntity):
"""Switch with proper error handling."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.coordinator.client.turn_on()
except MyDeviceError as err:
raise HomeAssistantError(f"Failed to turn on: {err}") from err
await self.coordinator.async_request_refresh()
```
## Translations
In `strings.json`:
```json
{
"entity": {
"switch": {
"power": {
"name": "Power"
},
"child_lock": {
"name": "Child lock"
},
"auto_mode": {
"name": "Auto mode"
}
}
}
}
```

View File

@@ -0,0 +1,396 @@
# Testing Reference
Testing patterns for Home Assistant integrations.
## Test Structure
```
tests/components/my_integration/
├── __init__.py
├── conftest.py # Shared fixtures
├── test_config_flow.py # Config flow tests (100% coverage required)
├── test_init.py # Integration setup tests
├── test_sensor.py # Sensor platform tests
├── test_diagnostics.py # Diagnostics tests
├── snapshots/ # Snapshot files
│ └── test_sensor.ambr
└── fixtures/ # Test data fixtures
└── device_data.json
```
## conftest.py
```python
"""Fixtures for My Integration tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.my_integration.const import DOMAIN
from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="My Device",
data={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_api_key",
},
unique_id="device_serial_123",
)
@pytest.fixture
def mock_client() -> Generator[MagicMock]:
"""Return a mocked client."""
with patch(
"homeassistant.components.my_integration.MyClient",
autospec=True,
) as client_mock:
client = client_mock.return_value
client.get_data = AsyncMock(
return_value=MyData.from_json(load_fixture("device_data.json", DOMAIN))
)
client.serial_number = "device_serial_123"
client.name = "My Device"
client.model = "Model X"
client.firmware_version = "1.2.3"
yield client
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to test."""
return [Platform.SENSOR, Platform.BINARY_SENSOR]
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: MagicMock,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.my_integration.PLATFORMS",
platforms,
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
```
## Config Flow Tests
**100% coverage required for all paths:**
```python
"""Test config flow for My Integration."""
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.my_integration.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
async def test_user_flow_success(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test successful user flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "My Device"
assert result["data"] == {
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
}
assert result["result"].unique_id == "device_serial_123"
async def test_user_flow_cannot_connect(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test connection error in user flow."""
mock_client.get_data.side_effect = ConnectionError("Cannot connect")
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_user_flow_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
) -> None:
"""Test already configured error."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "192.168.1.100",
CONF_API_KEY: "test_key",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_client: AsyncMock,
) -> None:
"""Test reauthentication flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_API_KEY: "new_api_key"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_API_KEY] == "new_api_key"
```
## Entity Tests with Snapshots
```python
"""Test sensor platform."""
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture
def platforms() -> list[Platform]:
"""Override platforms for sensor tests."""
return [Platform.SENSOR]
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensor entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_sensor_device_assignment(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensors are assigned to correct device."""
device = device_registry.async_get_device(
identifiers={("my_integration", "device_serial_123")}
)
assert device is not None
entities = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
for entity in entities:
assert entity.device_id == device.id
```
## Coordinator Tests
```python
"""Test coordinator."""
from unittest.mock import AsyncMock
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import UpdateFailed
async def test_coordinator_update_success(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test successful coordinator update."""
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
await coordinator.async_refresh()
assert coordinator.data.temperature == 21.5
assert coordinator.last_update_success
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test coordinator handles API error."""
mock_client.get_data.side_effect = MyError("Connection failed")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(UpdateFailed):
await coordinator._async_update_data()
async def test_coordinator_auth_failed(
hass: HomeAssistant,
mock_client: AsyncMock,
) -> None:
"""Test coordinator handles auth error."""
mock_client.get_data.side_effect = AuthError("Invalid token")
coordinator = MyCoordinator(hass, mock_config_entry, mock_client)
with pytest.raises(ConfigEntryAuthFailed):
await coordinator._async_update_data()
```
## Diagnostics Tests
```python
"""Test diagnostics."""
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.my_integration import snapshot_platform
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
assert await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
) == snapshot
```
## Common Fixtures
```python
from tests.common import MockConfigEntry, load_fixture
# Load JSON fixture
data = load_fixture("device_data.json", DOMAIN)
# Enable all entities (including disabled by default)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
# Freeze time
from freezegun.api import FrozenDateTimeFactory
async def test_with_frozen_time(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
) -> None:
freezer.tick(timedelta(minutes=5))
await hass.async_block_till_done()
```
## Update Snapshots
```bash
# Update snapshots
pytest tests/components/my_integration --snapshot-update
# Always re-run without flag to verify
pytest tests/components/my_integration
```
## Test Commands
```bash
# Run tests with coverage
pytest tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing \
--numprocesses=auto
# Run specific test
pytest tests/components/my_integration/test_config_flow.py::test_user_flow_success
# Quick test of changed files
pytest --timeout=10 --picked
```
## Best Practices
1. **Never access `hass.data` directly** - Use fixtures and proper setup
2. **Mock all external APIs** - Use fixtures with realistic JSON data
3. **Use snapshot testing** - For entity states and attributes
4. **Test error paths** - Connection errors, auth failures, invalid data
5. **Test edge cases** - Empty data, missing fields, None values
6. **>95% coverage required** - All code paths must be tested

View File

@@ -91,6 +91,7 @@ components: &components
- homeassistant/components/input_number/**
- homeassistant/components/input_select/**
- homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/logbook/**
- homeassistant/components/logger/**
- homeassistant/components/lovelace/**

View File

@@ -247,17 +247,11 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- name: Register yamllint problem matcher
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
@@ -316,7 +310,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: venv
key: &key-python-venv >-
@@ -380,7 +374,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
key: *key-apt-cache
@@ -431,7 +425,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -1193,6 +1187,8 @@ jobs:
- pytest-postgres
- pytest-mariadb
timeout-minutes: 10
permissions:
id-token: write
# codecov/test-results-action currently doesn't support tokenless uploads
# therefore we can't run it on forks
if: |
@@ -1204,8 +1200,9 @@ jobs:
with:
pattern: test-results-*
- name: Upload test results to Codecov
uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
report_type: test_results
fail_ci_if_error: true
verbose: true
token: ${{ secrets.CODECOV_TOKEN }}
use_oidc: true

View File

@@ -231,7 +231,7 @@ jobs:
- name: Detect duplicates using AI
id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o
system-prompt: |

View File

@@ -57,7 +57,7 @@ jobs:
- name: Detect language using AI
id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
with:
model: openai/gpt-4o-mini
system-prompt: |

View File

@@ -4,7 +4,7 @@
"owner": "check-executables-have-shebangs",
"pattern": [
{
"regexp": "^(.+):\\s(.+)$",
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
"file": 1,
"message": 2
}

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.0
rev: v0.14.13
hooks:
- id: ruff-check
args:

View File

@@ -455,6 +455,7 @@ homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.*
homeassistant.components.schedule.*
homeassistant.components.schlage.*

7
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST
/tests/components/min_max/ @gjohansson-ST
/homeassistant/components/minecraft_server/ @elmurato
/tests/components/minecraft_server/ @elmurato
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/moat/ @bdraco
@@ -1273,7 +1273,8 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato

View File

@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
def device_info(self) -> DeviceInfo:
"""Return device information about this AdGuard Home instance."""
if self._entry.source == SOURCE_HASSIO:
config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
config_url = "homeassistant://app/a0d7b954_adguard"
elif self.adguard.tls:
config_url = f"https://{self.adguard.host}:{self.adguard.port}"
else:

View File

@@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
Platform.SWITCH,
]

View File

@@ -63,6 +63,11 @@ class AirobotClimate(AirobotEntity, ClimateEntity):
_attr_min_temp = SETPOINT_TEMP_MIN
_attr_max_temp = SETPOINT_TEMP_MAX
def __init__(self, coordinator) -> None:
"""Initialize the climate entity."""
super().__init__(coordinator)
self._attr_unique_id = coordinator.data.status.device_id
@property
def _status(self) -> ThermostatStatus:
"""Get status from coordinator data."""

View File

@@ -24,8 +24,6 @@ class AirobotEntity(CoordinatorEntity[AirobotDataUpdateCoordinator]):
status = coordinator.data.status
settings = coordinator.data.settings
self._attr_unique_id = status.device_id
connections = set()
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
connections.add((CONNECTION_NETWORK_MAC, mac))

View File

@@ -9,6 +9,14 @@
"hysteresis_band": {
"default": "mdi:delta"
}
},
"switch": {
"actuator_exercise_disabled": {
"default": "mdi:valve"
},
"child_lock": {
"default": "mdi:lock"
}
}
}
}

View File

@@ -85,6 +85,14 @@
"heating_uptime": {
"name": "Heating uptime"
}
},
"switch": {
"actuator_exercise_disabled": {
"name": "Actuator exercise disabled"
},
"child_lock": {
"name": "Child lock"
}
}
},
"exceptions": {
@@ -105,6 +113,12 @@
},
"set_value_failed": {
"message": "Failed to set value: {error}"
},
"switch_turn_off_failed": {
"message": "Failed to turn off {switch}."
},
"switch_turn_on_failed": {
"message": "Failed to turn on {switch}."
}
}
}

View File

@@ -0,0 +1,118 @@
"""Switch platform for Airobot thermostat."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pyairobotrest.exceptions import AirobotError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirobotConfigEntry
from .const import DOMAIN
from .coordinator import AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotSwitchEntityDescription(SwitchEntityDescription):
"""Describes Airobot switch entity."""
is_on_fn: Callable[[AirobotDataUpdateCoordinator], bool]
turn_on_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
SWITCH_TYPES: tuple[AirobotSwitchEntityDescription, ...] = (
AirobotSwitchEntityDescription(
key="child_lock",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
is_on_fn=lambda coordinator: (
coordinator.data.settings.setting_flags.childlock_enabled
),
turn_on_fn=lambda coordinator: coordinator.client.set_child_lock(True),
turn_off_fn=lambda coordinator: coordinator.client.set_child_lock(False),
),
AirobotSwitchEntityDescription(
key="actuator_exercise_disabled",
translation_key="actuator_exercise_disabled",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
is_on_fn=lambda coordinator: (
coordinator.data.settings.setting_flags.actuator_exercise_disabled
),
turn_on_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
True
),
turn_off_fn=lambda coordinator: coordinator.client.toggle_actuator_exercise(
False
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot switch entities."""
coordinator = entry.runtime_data
async_add_entities(
AirobotSwitch(coordinator, description) for description in SWITCH_TYPES
)
class AirobotSwitch(AirobotEntity, SwitchEntity):
"""Representation of an Airobot switch."""
entity_description: AirobotSwitchEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
try:
await self.entity_description.turn_on_fn(self.coordinator)
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_turn_on_failed",
translation_placeholders={"switch": self.entity_description.key},
) from err
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
try:
await self.entity_description.turn_off_fn(self.coordinator)
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_turn_off_failed",
translation_placeholders={"switch": self.entity_description.key},
) from err
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,93 @@
"""Provides conditions for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.condition import (
Condition,
EntityStateConditionBase,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_required_features_condition(
domain: str, to_state: str, required_features: int
) -> type[EntityStateRequiredFeaturesCondition]:
"""Create an entity state condition class with required feature filtering."""
class CustomCondition(EntityStateRequiredFeaturesCondition):
"""Condition for entity state changes."""
_domain = domain
_states = {to_state}
_required_features = required_features
return CustomCondition
CONDITIONS: dict[str, type[Condition]] = {
"is_armed": make_entity_state_condition(
DOMAIN,
{
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"is_armed_away": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"is_armed_home": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"is_armed_night": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"is_armed_vacation": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the alarm control panel conditions."""
return CONDITIONS

View File

@@ -0,0 +1,52 @@
.condition_common: &condition_common
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_armed: *condition_common
is_armed_away:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common
is_triggered: *condition_common

View File

@@ -1,4 +1,27 @@
{
"conditions": {
"is_armed": {
"condition": "mdi:shield"
},
"is_armed_away": {
"condition": "mdi:shield-lock"
},
"is_armed_home": {
"condition": "mdi:shield-home"
},
"is_armed_night": {
"condition": "mdi:shield-moon"
},
"is_armed_vacation": {
"condition": "mdi:shield-airplane"
},
"is_disarmed": {
"condition": "mdi:shield-off"
},
"is_triggered": {
"condition": "mdi:bell-ring"
}
},
"entity_component": {
"_": {
"default": "mdi:shield",

View File

@@ -1,8 +1,82 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted alarms.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_armed": {
"description": "Tests if one or more alarms are armed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "Alarm is triggered"
}
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -76,6 +150,12 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Get the device class of an entity or UNDEFINED if not found."""
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class."""
"""Create an entity state trigger class with required feature filtering."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""

View File

@@ -5,9 +5,14 @@ from __future__ import annotations
import asyncio
import logging
from random import randrange
import sys
from typing import Any, cast
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -24,11 +29,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
HomeAssistantError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -42,18 +43,6 @@ from .const import (
SIGNAL_DISCONNECTED,
)
if sys.version_info < (3, 14):
from pyatv import connect, exceptions, scan
from pyatv.conf import AppleTV
from pyatv.const import DeviceModel, Protocol
from pyatv.convert import model_str
from pyatv.interface import AppleTV as AppleTVInterface, DeviceListener
else:
class DeviceListener:
"""Dummy class."""
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME_TV = "Apple TV"
@@ -64,30 +53,25 @@ BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE]
if sys.version_info < (3, 14):
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
else:
AUTH_EXCEPTIONS = ()
CONNECTION_TIMEOUT_EXCEPTIONS = ()
DEVICE_EXCEPTIONS = ()
AUTH_EXCEPTIONS = (
exceptions.AuthenticationError,
exceptions.InvalidCredentialsError,
exceptions.NoCredentialsError,
)
CONNECTION_TIMEOUT_EXCEPTIONS = (
OSError,
asyncio.CancelledError,
TimeoutError,
exceptions.ConnectionLostError,
exceptions.ConnectionFailedError,
)
DEVICE_EXCEPTIONS = (
exceptions.ProtocolError,
exceptions.NoServiceError,
exceptions.PairingError,
exceptions.BackOffError,
exceptions.DeviceIdMissingError,
)
type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
@@ -95,10 +79,6 @@ type AppleTvConfigEntry = ConfigEntry[AppleTVManager]
async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool:
"""Set up a config entry for Apple TV."""
if sys.version_info >= (3, 14):
raise HomeAssistantError(
"Apple TV is not supported on Python 3.14. Please use Python 3.13."
)
manager = AppleTVManager(hass, entry)
if manager.is_on:

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyatv", "srptools"],
"requirements": ["pyatv==0.16.1;python_version<'3.14'"],
"requirements": ["pyatv==0.17.0"],
"zeroconf": [
"_mediaremotetv._tcp.local.",
"_companion-link._tcp.local.",

View File

@@ -239,6 +239,15 @@ class AppleTvMediaPlayer(
"""
self.async_write_ha_state()
@callback
def volume_device_update(
self, output_device: OutputDevice, old_level: float, new_level: float
) -> None:
"""Output device volume was updated.
This is a callback function from pyatv.interface.AudioListener.
"""
@callback
def outputdevices_update(
self, old_devices: list[OutputDevice], new_devices: list[OutputDevice]

View File

@@ -0,0 +1,23 @@
"""Provides conditions for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the assist satellite conditions."""
return CONDITIONS

View File

@@ -0,0 +1,19 @@
.condition_common: &condition_common
target:
entity:
domain: assist_satellite
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_idle: *condition_common
is_listening: *condition_common
is_processing: *condition_common
is_responding: *condition_common

View File

@@ -1,4 +1,18 @@
{
"conditions": {
"is_idle": {
"condition": "mdi:chat-sleep"
},
"is_listening": {
"condition": "mdi:chat-question"
},
"is_processing": {
"condition": "mdi:chat-processing"
},
"is_responding": {
"condition": "mdi:chat-alert"
}
},
"entity_component": {
"_": {
"default": "mdi:account-voice"

View File

@@ -1,8 +1,52 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_idle": {
"description": "Tests if one or more Assist satellites are idle.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "Satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "Satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "Satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "Satellite is responding"
}
},
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -21,6 +65,12 @@
"sentences": "Sentences"
}
},
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -56,7 +56,7 @@ from homeassistant.core import (
valid_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import condition as condition_helper, config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.issue_registry import (
@@ -123,8 +123,11 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"fan",
"light",
"siren",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
@@ -551,7 +554,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
automation_id: str | None,
name: str,
trigger_config: list[ConfigType],
cond_func: IfAction | None,
condition: IfAction | None,
action_script: Script,
initial_state: bool | None,
variables: ScriptVariables | None,
@@ -564,7 +567,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
self._attr_name = name
self._trigger_config = trigger_config
self._async_detach_triggers: CALLBACK_TYPE | None = None
self._cond_func = cond_func
self._condition = condition
self.action_script = action_script
self.action_script.change_listener = self.async_write_ha_state
self._initial_state = initial_state
@@ -599,6 +602,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced labels."""
referenced = self.action_script.referenced_labels
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(
conf, ATTR_LABEL_ID
)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@@ -608,6 +617,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced floors."""
referenced = self.action_script.referenced_floors
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(
conf, ATTR_FLOOR_ID
)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
return referenced
@@ -617,6 +632,10 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced areas."""
referenced = self.action_script.referenced_areas
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_targets(conf, ATTR_AREA_ID)
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
return referenced
@@ -633,9 +652,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced devices."""
referenced = self.action_script.referenced_devices
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_devices(conf)
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_devices(conf)
for conf in self._trigger_config:
referenced |= set(_trigger_extract_devices(conf))
@@ -647,9 +666,9 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return a set of referenced entities."""
referenced = self.action_script.referenced_entities
if self._cond_func is not None:
for conf in self._cond_func.config:
referenced |= condition.async_extract_entities(conf)
if self._condition is not None:
for conf in self._condition.config:
referenced |= condition_helper.async_extract_entities(conf)
for conf in self._trigger_config:
for entity_id in _trigger_extract_entities(conf):
@@ -769,8 +788,8 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
if (
not skip_condition
and self._cond_func is not None
and not self._cond_func(variables)
and self._condition is not None
and not self._condition(variables)
):
self._logger.debug(
"Conditions not met, aborting automation. Condition summary: %s",
@@ -1032,12 +1051,12 @@ async def _create_automation_entities(
)
if CONF_CONDITIONS in config_block:
cond_func = await _async_process_if(hass, name, config_block)
condition = await _async_process_if(hass, name, config_block)
if cond_func is None:
if condition is None:
continue
else:
cond_func = None
condition = None
# Add trigger variables to variables
variables = None
@@ -1055,7 +1074,7 @@ async def _create_automation_entities(
automation_id,
name,
config_block[CONF_TRIGGERS],
cond_func,
condition,
action_script,
initial_state,
variables,
@@ -1197,7 +1216,7 @@ async def _async_process_if(
if_configs = config[CONF_CONDITIONS]
try:
if_action = await condition.async_conditions_from_config(
if_action = await condition_helper.async_conditions_from_config(
hass, if_configs, LOGGER, name
)
except HomeAssistantError as ex:

View File

@@ -85,9 +85,9 @@
}
},
"moving": {
"default": "mdi:arrow-right",
"default": "mdi:octagon",
"state": {
"on": "mdi:octagon"
"on": "mdi:arrow-right"
}
},
"occupancy": {

View File

@@ -1,5 +1,7 @@
"""BleBox sensor entities."""
from datetime import datetime
import blebox_uniapi.sensor
from homeassistant.components.sensor import (
@@ -146,7 +148,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
return self._feature.native_value
@property
def last_reset(self):
def last_reset(self) -> datetime | None:
"""Return the time when the sensor was last reset, if implemented."""
native_implementation = getattr(self._feature, "last_reset", None)

View File

@@ -64,6 +64,7 @@ def _ws_with_blueprint_domain(
return with_domain_blueprints
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/list",
@@ -97,6 +98,7 @@ async def ws_list_blueprints(
connection.send_result(msg["id"], results)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/import",
@@ -150,6 +152,7 @@ async def ws_import_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/save",
@@ -206,6 +209,7 @@ async def ws_save_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/delete",
@@ -233,6 +237,7 @@ async def ws_delete_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/substitute",

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.9.0"],
"requirements": ["hass-nabucasa==1.11.0"],
"single_config_entry": true
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["compit"],
"quality_scale": "bronze",
"requirements": ["compit-inext-api==0.3.4"]
"requirements": ["compit-inext-api==0.4.2"]
}

View File

@@ -49,11 +49,11 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Concord232 alarm control panel platform."""
name = config[CONF_NAME]
code = config.get(CONF_CODE)
mode = config[CONF_MODE]
host = config[CONF_HOST]
port = config[CONF_PORT]
name: str = config[CONF_NAME]
code: str | None = config.get(CONF_CODE)
mode: str = config[CONF_MODE]
host: str = config[CONF_HOST]
port: int = config[CONF_PORT]
url = f"http://{host}:{port}"
@@ -72,7 +72,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
def __init__(self, url, name, code, mode):
def __init__(self, url: str, name: str, code: str | None, mode: str) -> None:
"""Initialize the Concord232 alarm panel."""
self._attr_name = name
@@ -125,7 +125,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
return
self._alarm.arm("away")
def _validate_code(self, code, state):
def _validate_code(self, code: str | None, state: AlarmControlPanelState) -> bool:
"""Validate given code."""
if self._code is None:
return True

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import logging
from typing import Any
from concord232 import client as concord232_client
import requests
@@ -29,8 +30,7 @@ CONF_ZONE_TYPES = "zone_types"
DEFAULT_HOST = "localhost"
DEFAULT_NAME = "Alarm"
DEFAULT_PORT = "5007"
DEFAULT_SSL = False
DEFAULT_PORT = 5007
SCAN_INTERVAL = datetime.timedelta(seconds=10)
@@ -56,10 +56,10 @@ def setup_platform(
) -> None:
"""Set up the Concord232 binary sensor platform."""
host = config[CONF_HOST]
port = config[CONF_PORT]
exclude = config[CONF_EXCLUDE_ZONES]
zone_types = config[CONF_ZONE_TYPES]
host: str = config[CONF_HOST]
port: int = config[CONF_PORT]
exclude: list[int] = config[CONF_EXCLUDE_ZONES]
zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES]
sensors = []
try:
@@ -84,7 +84,6 @@ def setup_platform(
if zone["number"] not in exclude:
sensors.append(
Concord232ZoneSensor(
hass,
client,
zone,
zone_types.get(zone["number"], get_opening_type(zone)),
@@ -110,26 +109,25 @@ def get_opening_type(zone):
class Concord232ZoneSensor(BinarySensorEntity):
"""Representation of a Concord232 zone as a sensor."""
def __init__(self, hass, client, zone, zone_type):
def __init__(
self,
client: concord232_client.Client,
zone: dict[str, Any],
zone_type: BinarySensorDeviceClass,
) -> None:
"""Initialize the Concord232 binary sensor."""
self._hass = hass
self._client = client
self._zone = zone
self._number = zone["number"]
self._zone_type = zone_type
self._attr_device_class = zone_type
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@property
def name(self):
def name(self) -> str:
"""Return the name of the binary sensor."""
return self._zone["name"]
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
# True means "faulted" or "open" or "abnormal state"
return bool(self._zone["state"] != "Normal")
@@ -145,5 +143,5 @@ class Concord232ZoneSensor(BinarySensorEntity):
if hasattr(self._client, "zones"):
self._zone = next(
(x for x in self._client.zones if x["number"] == self._number), None
x for x in self._client.zones if x["number"] == self._number
)

View File

@@ -10,7 +10,7 @@ LOGGER = logging.getLogger(__package__)
DOMAIN = "deconz"
HASSIO_CONFIGURATION_URL = "homeassistant://hassio/ingress/core_deconz"
HASSIO_CONFIGURATION_URL = "homeassistant://app/core_deconz"
CONF_BRIDGE_ID = "bridgeid"
CONF_GROUP_ID_BASE = "group_id_base"

View File

@@ -1,6 +1,7 @@
"""Support for Digital Ocean."""
from datetime import timedelta
from __future__ import annotations
import logging
import digitalocean
@@ -12,27 +13,12 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DATA_DIGITAL_OCEAN = "data_do"
DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR]
DOMAIN = "digital_ocean"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -16,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
from .const import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -65,6 +66,7 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
"""Representation of a Digital Ocean droplet sensor."""
_attr_attribution = ATTRIBUTION
_attr_device_class = BinarySensorDeviceClass.MOVING
def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor."""
@@ -79,17 +81,12 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
return self.data.name
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.data.status == "active"
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor."""
return BinarySensorDeviceClass.MOVING
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -0,0 +1,30 @@
"""Support for Digital Ocean."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import DigitalOcean
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DOMAIN = "digital_ocean"
DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
from .const import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -80,12 +80,12 @@ class DigitalOceanSwitch(SwitchEntity):
return self.data.name
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if switch is on."""
return self.data.status == "active"
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -1,6 +1,7 @@
"""Support for Ebusd daemon for communication with eBUS heating systems."""
import logging
from typing import Any
import ebusdpy
import voluptuous as vol
@@ -17,7 +18,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, SENSOR_TYPES
from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
@@ -28,9 +29,9 @@ CACHE_TTL = 900
SERVICE_EBUSD_WRITE = "ebusd_write"
def verify_ebusd_config(config):
def verify_ebusd_config(config: ConfigType) -> ConfigType:
"""Verify eBusd config."""
circuit = config[CONF_CIRCUIT]
circuit: str = config[CONF_CIRCUIT]
for condition in config[CONF_MONITORED_CONDITIONS]:
if condition not in SENSOR_TYPES[circuit]:
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
@@ -59,17 +60,17 @@ CONFIG_SCHEMA = vol.Schema(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the eBusd component."""
_LOGGER.debug("Integration setup started")
conf = config[DOMAIN]
name = conf[CONF_NAME]
circuit = conf[CONF_CIRCUIT]
monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
conf: ConfigType = config[DOMAIN]
name: str = conf[CONF_NAME]
circuit: str = conf[CONF_CIRCUIT]
monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
try:
ebusdpy.init(server_address)
except (TimeoutError, OSError):
return False
hass.data[DOMAIN] = EbusdData(server_address, circuit)
hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
sensor_config = {
CONF_MONITORED_CONDITIONS: monitored_conditions,
"client_name": name,
@@ -77,7 +78,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
_LOGGER.debug("Ebusd integration setup completed")
return True
@@ -86,13 +87,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class EbusdData:
"""Get the latest data from Ebusd."""
def __init__(self, address, circuit):
def __init__(self, address: tuple[str, int], circuit: str) -> None:
"""Initialize the data object."""
self._circuit = circuit
self._address = address
self.value = {}
self.value: dict[str, Any] = {}
def update(self, name, stype):
def update(self, name: str, stype: int) -> None:
"""Call the Ebusd API to update the data."""
try:
_LOGGER.debug("Opening socket to ebusd %s", name)

View File

@@ -1,5 +1,9 @@
"""Constants for ebus component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
PERCENTAGE,
@@ -8,277 +12,283 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import EbusdData
DOMAIN = "ebusd"
EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
SENSOR_TYPES = {
type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
"700": {
"ActualFlowTemperatureDesired": [
"ActualFlowTemperatureDesired": (
"Hc1ActualFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"MaxFlowTemperatureDesired": [
),
"MaxFlowTemperatureDesired": (
"Hc1MaxFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"MinFlowTemperatureDesired": [
),
"MinFlowTemperatureDesired": (
"Hc1MinFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
"HCSummerTemperatureLimit": [
),
"PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
"HCSummerTemperatureLimit": (
"Hc1SummerTempLimit",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
],
"HolidayTemperature": [
),
"HolidayTemperature": (
"HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWTemperatureDesired": [
),
"HWTemperatureDesired": (
"HwcTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWActualTemperature": [
),
"HWActualTemperature": (
"HwcStorageTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
"HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
"HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
"HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
"HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
"HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
"HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
"HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
"WaterPressure": [
),
"HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
"HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
"HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
"HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
"HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
"HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
"HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
"HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
"WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:water-pump",
0,
SensorDeviceClass.PRESSURE,
],
"Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
"Zone1NightTemperature": [
),
"Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
"Zone1NightTemperature": (
"z1NightTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-night",
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1DayTemperature": [
),
"Zone1DayTemperature": (
"z1DayTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1HolidayTemperature": [
),
"Zone1HolidayTemperature": (
"z1HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1RoomTemperature": [
),
"Zone1RoomTemperature": (
"z1RoomTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1ActualRoomTemperatureDesired": [
),
"Zone1ActualRoomTemperatureDesired": (
"z1ActualRoomTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
"Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
"Zone1TimerWednesday": [
),
"Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
"Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
"Zone1TimerWednesday": (
"z1Timer.Wednesday",
None,
"mdi:timer-outline",
1,
None,
],
"Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
"Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
"Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
"ContinuosHeating": [
),
"Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
"Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
"Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
"ContinuosHeating": (
"ContinuosHeating",
UnitOfTemperature.CELSIUS,
"mdi:weather-snowy",
0,
SensorDeviceClass.TEMPERATURE,
],
"PowerEnergyConsumptionLastMonth": [
),
"PowerEnergyConsumptionLastMonth": (
"PrEnergySumHcLastMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"PowerEnergyConsumptionThisMonth": [
),
"PowerEnergyConsumptionThisMonth": (
"PrEnergySumHcThisMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
),
},
"ehp": {
"HWTemperature": [
"HWTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"OutsideTemp": [
),
"OutsideTemp": (
"OutsideTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
),
},
"bai": {
"HotWaterTemperature": [
"HotWaterTemperature": (
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"StorageTemperature": [
),
"StorageTemperature": (
"StorageTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"DesiredStorageTemperature": [
),
"DesiredStorageTemperature": (
"StorageTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"OutdoorsTemperature": [
),
"OutdoorsTemperature": (
"OutdoorstempSensor",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"WaterPressure": [
),
"WaterPressure": (
"WaterPressure",
UnitOfPressure.BAR,
"mdi:pipe",
4,
SensorDeviceClass.PRESSURE,
],
"AverageIgnitionTime": [
),
"AverageIgnitionTime": (
"averageIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"MaximumIgnitionTime": [
),
"MaximumIgnitionTime": (
"maxIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"MinimumIgnitionTime": [
),
"MinimumIgnitionTime": (
"minIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
],
"ReturnTemperature": [
),
"ReturnTemperature": (
"ReturnTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
"HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
"DesiredFlowTemperature": [
),
"CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
"HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
"DesiredFlowTemperature": (
"FlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
],
"FlowTemperature": [
),
"FlowTemperature": (
"FlowTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
],
"Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
"PowerEnergyConsumptionHeatingCircuit": [
),
"Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
"PowerEnergyConsumptionHeatingCircuit": (
"PrEnergySumHc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"PowerEnergyConsumptionHotWaterCircuit": [
),
"PowerEnergyConsumptionHotWaterCircuit": (
"PrEnergySumHwc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
"HeatingPartLoad": [
),
"RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
"HeatingPartLoad": (
"PartloadHcKW",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
],
"StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
"ModulationPercentage": [
),
"StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
"ModulationPercentage": (
"ModulationTempDesired",
PERCENTAGE,
"mdi:percent",
0,
None,
],
),
},
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import datetime
import logging
from typing import Any, cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
@@ -11,7 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN
from . import EbusdData
from .const import EBUSD_DATA, SensorSpecs
TIME_FRAME1_BEGIN = "time_frame1_begin"
TIME_FRAME1_END = "time_frame1_end"
@@ -33,9 +35,9 @@ def setup_platform(
"""Set up the Ebus sensor."""
if not discovery_info:
return
ebusd_api = hass.data[DOMAIN]
monitored_conditions = discovery_info["monitored_conditions"]
name = discovery_info["client_name"]
ebusd_api = hass.data[EBUSD_DATA]
monitored_conditions: list[str] = discovery_info["monitored_conditions"]
name: str = discovery_info["client_name"]
add_entities(
(
@@ -49,9 +51,8 @@ def setup_platform(
class EbusdSensor(SensorEntity):
"""Ebusd component sensor methods definition."""
def __init__(self, data, sensor, name):
def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
"""Initialize the sensor."""
self._state = None
self._client_name = name
(
self._name,
@@ -63,20 +64,15 @@ class EbusdSensor(SensorEntity):
self.data = data
@property
def name(self):
def name(self) -> str:
"""Return the name of the sensor."""
return f"{self._client_name} {self._name}"
@property
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the device state attributes."""
if self._type == 1 and self._state is not None:
schedule = {
if self._type == 1 and (native_value := self.native_value) is not None:
schedule: dict[str, str | None] = {
TIME_FRAME1_BEGIN: None,
TIME_FRAME1_END: None,
TIME_FRAME2_BEGIN: None,
@@ -84,7 +80,7 @@ class EbusdSensor(SensorEntity):
TIME_FRAME3_BEGIN: None,
TIME_FRAME3_END: None,
}
time_frame = self._state.split(";")
time_frame = cast(str, native_value).split(";")
for index, item in enumerate(sorted(schedule.items())):
if index < len(time_frame):
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
@@ -101,12 +97,12 @@ class EbusdSensor(SensorEntity):
return self._device_class
@property
def icon(self):
def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit of measurement."""
return self._unit_of_measurement
@@ -118,6 +114,6 @@ class EbusdSensor(SensorEntity):
if self._name not in self.data.value:
return
self._state = self.data.value[self._name]
self._attr_native_value = self.data.value[self._name]
except RuntimeError:
_LOGGER.debug("EbusdData.update exception")

View File

@@ -18,6 +18,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
@@ -35,7 +36,7 @@ DEFAULT_REPORT_SERVER_PORT = 52010
DEFAULT_VERSION = "GATE-01"
DOMAIN = "egardia"
EGARDIA_DEVICE = "egardiadevice"
EGARDIA_DEVICE: HassKey[egardiadevice.EgardiaDevice] = HassKey(DOMAIN)
EGARDIA_NAME = "egardianame"
EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes"
EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from pythonegardia.egardiadevice import EgardiaDevice
import requests
from homeassistant.components.alarm_control_panel import (
@@ -11,6 +12,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -47,10 +49,10 @@ def setup_platform(
if discovery_info is None:
return
device = EgardiaAlarm(
discovery_info["name"],
discovery_info[CONF_NAME],
hass.data[EGARDIA_DEVICE],
discovery_info[CONF_REPORT_SERVER_ENABLED],
discovery_info.get(CONF_REPORT_SERVER_CODES),
discovery_info[CONF_REPORT_SERVER_CODES],
discovery_info[CONF_REPORT_SERVER_PORT],
)
@@ -67,8 +69,13 @@ class EgardiaAlarm(AlarmControlPanelEntity):
)
def __init__(
self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010
):
self,
name: str,
egardiasystem: EgardiaDevice,
rs_enabled: bool,
rs_codes: dict[str, list[str]],
rs_port: int,
) -> None:
"""Initialize the Egardia alarm."""
self._attr_name = name
self._egardiasystem = egardiasystem
@@ -85,9 +92,7 @@ class EgardiaAlarm(AlarmControlPanelEntity):
@property
def should_poll(self) -> bool:
"""Poll if no report server is enabled."""
if not self._rs_enabled:
return True
return False
return not self._rs_enabled
def handle_status_event(self, event):
"""Handle the Egardia system status event."""

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from pythonegardia.egardiadevice import EgardiaDevice
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -51,30 +52,20 @@ async def async_setup_platform(
class EgardiaBinarySensor(BinarySensorEntity):
"""Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
def __init__(self, sensor_id, name, egardia_system, device_class):
def __init__(
self,
sensor_id: str,
name: str,
egardia_system: EgardiaDevice,
device_class: BinarySensorDeviceClass | None,
) -> None:
"""Initialize the sensor device."""
self._id = sensor_id
self._name = name
self._state = None
self._device_class = device_class
self._attr_name = name
self._attr_device_class = device_class
self._egardia_system = egardia_system
def update(self) -> None:
"""Update the status."""
egardia_input = self._egardia_system.getsensorstate(self._id)
self._state = STATE_ON if egardia_input else STATE_OFF
@property
def name(self):
"""Return the name of the device."""
return self._name
@property
def is_on(self):
"""Whether the device is switched on."""
return self._state == STATE_ON
@property
def device_class(self) -> BinarySensorDeviceClass | None:
"""Return the device class."""
return self._device_class
self._attr_is_on = bool(egardia_input)

View File

@@ -18,12 +18,13 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
_LOGGER = logging.getLogger(__name__)
DOMAIN = "envisalink"
DATA_EVL = "envisalink"
DATA_EVL: HassKey[EnvisalinkAlarmPanel] = HassKey(DOMAIN)
CONF_EVL_KEEPALIVE = "keepalive_interval"
CONF_EVL_PORT = "port"

View File

@@ -3,7 +3,9 @@
from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
@@ -22,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PANIC,
CONF_PARTITIONNAME,
CONF_PARTITIONS,
DATA_EVL,
DOMAIN,
PARTITION_SCHEMA,
@@ -51,15 +54,14 @@ async def async_setup_platform(
"""Perform the setup for Envisalink alarm panels."""
if not discovery_info:
return
configured_partitions = discovery_info["partitions"]
code = discovery_info[CONF_CODE]
panic_type = discovery_info[CONF_PANIC]
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
code: str | None = discovery_info[CONF_CODE]
panic_type: str = discovery_info[CONF_PANIC]
entities = []
for part_num in configured_partitions:
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
for part_num, part_config in configured_partitions.items():
entity_config_data = PARTITION_SCHEMA(part_config)
entity = EnvisalinkAlarm(
hass,
part_num,
entity_config_data[CONF_PARTITIONNAME],
code,
@@ -103,8 +105,14 @@ class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity):
)
def __init__(
self, hass, partition_number, alarm_name, code, panic_type, info, controller
):
self,
partition_number: int,
alarm_name: str,
code: str | None,
panic_type: str,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
"""Initialize the alarm panel."""
self._partition_number = partition_number
self._panic_type = panic_type

View File

@@ -4,6 +4,9 @@ from __future__ import annotations
import datetime
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -16,7 +19,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA
from . import (
CONF_ZONENAME,
CONF_ZONES,
CONF_ZONETYPE,
DATA_EVL,
SIGNAL_ZONE_UPDATE,
ZONE_SCHEMA,
)
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -31,13 +41,12 @@ async def async_setup_platform(
"""Set up the Envisalink binary sensor entities."""
if not discovery_info:
return
configured_zones = discovery_info["zones"]
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
entities = []
for zone_num in configured_zones:
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
for zone_num, zone_data in configured_zones.items():
entity_config_data = ZONE_SCHEMA(zone_data)
entity = EnvisalinkBinarySensor(
hass,
zone_num,
entity_config_data[CONF_ZONENAME],
entity_config_data[CONF_ZONETYPE],
@@ -52,9 +61,16 @@ async def async_setup_platform(
class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Representation of an Envisalink binary sensor."""
def __init__(self, hass, zone_number, zone_name, zone_type, info, controller):
def __init__(
self,
zone_number: int,
zone_name: str,
zone_type: BinarySensorDeviceClass,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
"""Initialize the binary_sensor."""
self._zone_type = zone_type
self._attr_device_class = zone_type
self._zone_number = zone_number
_LOGGER.debug("Setting up zone: %s", zone_name)
@@ -69,9 +85,9 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
)
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
attr = {}
attr: dict[str, Any] = {}
# The Envisalink library returns a "last_fault" value that's the
# number of seconds since the last fault, up to a maximum of 327680
@@ -104,11 +120,6 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
"""Return true if sensor is on."""
return self._info["status"]["open"]
@property
def device_class(self) -> BinarySensorDeviceClass:
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@callback
def async_update_callback(self, zone):
"""Update the zone's state, if needed."""

View File

@@ -1,5 +1,9 @@
"""Support for Envisalink devices."""
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.helpers.entity import Entity
@@ -8,13 +12,10 @@ class EnvisalinkEntity(Entity):
_attr_should_poll = False
def __init__(self, name, info, controller):
def __init__(
self, name: str, info: dict[str, Any], controller: EnvisalinkAlarmPanel
) -> None:
"""Initialize the device."""
self._controller = controller
self._info = info
self._name = name
@property
def name(self):
"""Return the name of the device."""
return self._name
self._attr_name = name

View File

@@ -3,6 +3,9 @@
from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant, callback
@@ -12,6 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import (
CONF_PARTITIONNAME,
CONF_PARTITIONS,
DATA_EVL,
PARTITION_SCHEMA,
SIGNAL_KEYPAD_UPDATE,
@@ -31,13 +35,12 @@ async def async_setup_platform(
"""Perform the setup for Envisalink sensor entities."""
if not discovery_info:
return
configured_partitions = discovery_info["partitions"]
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
entities = []
for part_num in configured_partitions:
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
for part_num, part_config in configured_partitions.items():
entity_config_data = PARTITION_SCHEMA(part_config)
entity = EnvisalinkSensor(
hass,
entity_config_data[CONF_PARTITIONNAME],
part_num,
hass.data[DATA_EVL].alarm_state["partition"][part_num],
@@ -52,9 +55,16 @@ async def async_setup_platform(
class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
"""Representation of an Envisalink keypad."""
def __init__(self, hass, partition_name, partition_number, info, controller):
_attr_icon = "mdi:alarm"
def __init__(
self,
partition_name: str,
partition_number: int,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
"""Initialize the sensor."""
self._icon = "mdi:alarm"
self._partition_number = partition_number
_LOGGER.debug("Setting up sensor for partition: %s", partition_name)
@@ -73,11 +83,6 @@ class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
)
)
@property
def icon(self):
"""Return the icon if any."""
return self._icon
@property
def native_value(self):
"""Return the overall state."""

View File

@@ -5,13 +5,21 @@ from __future__ import annotations
import logging
from typing import Any
from pyenvisalink import EnvisalinkAlarmPanel
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA
from . import (
CONF_ZONENAME,
CONF_ZONES,
DATA_EVL,
SIGNAL_ZONE_BYPASS_UPDATE,
ZONE_SCHEMA,
)
from .entity import EnvisalinkEntity
_LOGGER = logging.getLogger(__name__)
@@ -26,16 +34,15 @@ async def async_setup_platform(
"""Set up the Envisalink switch entities."""
if not discovery_info:
return
configured_zones = discovery_info["zones"]
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
entities = []
for zone_num in configured_zones:
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
for zone_num, zone_data in configured_zones.items():
entity_config_data = ZONE_SCHEMA(zone_data)
zone_name = f"{entity_config_data[CONF_ZONENAME]}_bypass"
_LOGGER.debug("Setting up zone_bypass switch: %s", zone_name)
entity = EnvisalinkSwitch(
hass,
zone_num,
zone_name,
hass.data[DATA_EVL].alarm_state["zone"][zone_num],
@@ -49,7 +56,13 @@ async def async_setup_platform(
class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
"""Representation of an Envisalink switch."""
def __init__(self, hass, zone_number, zone_name, info, controller):
def __init__(
self,
zone_number: int,
zone_name: str,
info: dict[str, Any],
controller: EnvisalinkAlarmPanel,
) -> None:
"""Initialize the switch."""
self._zone_number = zone_number

View File

@@ -1034,7 +1034,7 @@ def _async_setup_device_registry(
and dashboard.data
and dashboard.data.get(device_info.name)
):
configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}"
configuration_url = f"homeassistant://app/{dashboard.addon_slug}"
manufacturer = "espressif"
if device_info.manufacturer:

View File

@@ -7,7 +7,7 @@ from enum import StrEnum
from typing import Final
DOMAIN: Final = "essent"
UPDATE_INTERVAL: Final = timedelta(hours=12)
UPDATE_INTERVAL: Final = timedelta(hours=1)
ATTRIBUTION: Final = "Data provided by Essent"

View File

@@ -14,7 +14,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is off"
"name": "Fan is off"
},
"is_on": {
"description": "Tests if one or more fans are on.",
@@ -24,7 +24,7 @@
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
"name": "If a fan is on"
"name": "Fan is on"
}
},
"device_automation": {

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.11"]
"requirements": ["pyfirefly==0.1.12"]
}

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260107.1"]
"requirements": ["home-assistant-frontend==20260107.2"]
}

View File

@@ -7,20 +7,11 @@
"benzene": {
"default": "mdi:molecule"
},
"nitrogen_dioxide": {
"default": "mdi:molecule"
},
"nitrogen_monoxide": {
"default": "mdi:molecule"
},
"non_methane_hydrocarbons": {
"default": "mdi:molecule"
},
"ozone": {
"default": "mdi:molecule"
},
"sulphur_dioxide": {
"default": "mdi:molecule"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==2.1.2"]
"requirements": ["google_air_quality_api==3.0.0"]
}

View File

@@ -13,7 +13,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
CONF_LATITUDE,
CONF_LONGITUDE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -114,6 +118,7 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
native_unit_of_measurement_fn=lambda x: x.pollutants.co.concentration.units,
exists_fn=lambda x: "co" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.co.concentration.value,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
AirQualitySensorEntityDescription(
key="nh3",
@@ -141,16 +146,16 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
),
AirQualitySensorEntityDescription(
key="no2",
translation_key="nitrogen_dioxide",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.no2.concentration.units,
exists_fn=lambda x: "no2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.no2.concentration.value,
),
AirQualitySensorEntityDescription(
key="o3",
translation_key="ozone",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.OZONE,
native_unit_of_measurement_fn=lambda x: x.pollutants.o3.concentration.units,
exists_fn=lambda x: "o3" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.o3.concentration.value,
@@ -173,8 +178,8 @@ AIR_QUALITY_SENSOR_TYPES: tuple[AirQualitySensorEntityDescription, ...] = (
),
AirQualitySensorEntityDescription(
key="so2",
translation_key="sulphur_dioxide",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.SULPHUR_DIOXIDE,
native_unit_of_measurement_fn=lambda x: x.pollutants.so2.concentration.units,
exists_fn=lambda x: "so2" in {p.code for p in x.pollutants},
value_fn=lambda x: x.pollutants.so2.concentration.value,

View File

@@ -205,21 +205,12 @@
"so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
"nitrogen_dioxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]"
},
"nitrogen_monoxide": {
"name": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]"
},
"non_methane_hydrocarbons": {
"name": "Non-methane hydrocarbons"
},
"ozone": {
"name": "[%key:component::sensor::entity_component::ozone::name%]"
},
"sulphur_dioxide": {
"name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
},
"uaqi": {
"name": "Universal Air Quality Index"
},

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-genai==1.56.0"]
"requirements": ["google-genai==1.59.0"]
}

View File

@@ -83,6 +83,9 @@
"invalid_credentials": "Input is incomplete. You must provide either your login details or an API token",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "[%key:common::config_flow::initiate_flow::account%]"
},
"step": {
"advanced": {
"data": {

View File

@@ -16,7 +16,7 @@ from aiohasupervisor.models import GreenOptions, YellowOptions # noqa: F401
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components import panel_custom
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
@@ -292,6 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
return False
async_load_websocket_api(hass)
frontend.async_register_built_in_panel(hass, "app")
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)

View File

@@ -6,7 +6,7 @@ from typing import Any
from aiohttp import web
from homeassistant.components import frontend, panel_custom
from homeassistant.components import frontend
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
@@ -33,7 +33,7 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio: HassIO) -> None:
# _register_panel never suspends and is only
# a coroutine because it would be a breaking change
# to make it a normal function
await _register_panel(hass, addon, data)
_register_panel(hass, addon, data)
class HassIOAddonPanel(HomeAssistantView):
@@ -58,7 +58,7 @@ class HassIOAddonPanel(HomeAssistantView):
data = panels[addon]
# Register panel
await _register_panel(self.hass, addon, data)
_register_panel(self.hass, addon, data)
return web.Response()
async def delete(self, request: web.Request, addon: str) -> web.Response:
@@ -76,18 +76,14 @@ class HassIOAddonPanel(HomeAssistantView):
return {}
async def _register_panel(
hass: HomeAssistant, addon: str, data: dict[str, Any]
) -> None:
def _register_panel(hass: HomeAssistant, addon: str, data: dict[str, Any]):
"""Init coroutine to register the panel."""
await panel_custom.async_register_panel(
frontend.async_register_built_in_panel(
hass,
"app",
frontend_url_path=addon,
webcomponent_name="hassio-main",
sidebar_title=data[ATTR_TITLE],
sidebar_icon=data[ATTR_ICON],
js_url="/api/hassio/app/entrypoint.js",
embed_iframe=True,
require_admin=data[ATTR_ADMIN],
config={"ingress": addon},
config={"addon": addon},
)

View File

@@ -19,6 +19,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFuryButtonEntityDescription(ButtonEntityDescription):

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hdfury",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["hdfury==1.3.1"]
"quality_scale": "silver",
"requirements": ["hdfury==1.4.2"]
}

View File

@@ -35,11 +35,11 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Integration has no authentication flow.
test-coverage: todo
test-coverage: done
# Gold
devices: done

View File

@@ -20,6 +20,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry, HDFuryCoordinator
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFurySelectEntityDescription(SelectEntityDescription):
@@ -77,13 +79,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[HDFuryEntity] = []
for description in SELECT_PORTS:
if description.key not in coordinator.data.info:
continue
entities.append(HDFurySelect(coordinator, description))
entities: list[HDFuryEntity] = [
HDFurySelect(coordinator, description)
for description in SELECT_PORTS
if description.key in coordinator.data.info
]
# Add OPMODE select if present
if "opmode" in coordinator.data.info:

View File

@@ -8,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 0
SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="RX0",

View File

@@ -16,6 +16,8 @@ from .const import DOMAIN
from .coordinator import HDFuryConfigEntry
from .entity import HDFuryEntity
PARALLEL_UPDATES = 1
@dataclass(kw_only=True, frozen=True)
class HDFurySwitchEntityDescription(SwitchEntityDescription):

View File

@@ -6,10 +6,7 @@ from dataclasses import dataclass
from pyHomee.const import AttributeType, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -17,17 +14,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from . import HomeeConfigEntry
from .const import (
DOMAIN,
HOMEE_UNIT_TO_HA_UNIT,
OPEN_CLOSE_MAP,
OPEN_CLOSE_MAP_REVERSED,
@@ -109,11 +99,6 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.CURRENT_VALVE_POSITION: HomeeSensorEntityDescription(
key="valve_position",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.DAWN: HomeeSensorEntityDescription(
key="dawn",
device_class=SensorDeviceClass.ILLUMINANCE,
@@ -294,57 +279,12 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
)
def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
"""Get list of related automations and scripts."""
used_in = automations_with_entity(hass, entity_id)
used_in += scripts_with_entity(hass, entity_id)
return used_in
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
ent_reg = er.async_get(hass)
def add_deprecated_entity(
attribute: HomeeAttribute, description: HomeeSensorEntityDescription
) -> list[HomeeSensor]:
"""Add deprecated entities."""
deprecated_entities: list[HomeeSensor] = []
entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid):
entity_entry = ent_reg.async_get(entity_id)
if entity_entry and entity_entry.disabled:
ent_reg.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_uid}",
)
elif entity_entry:
deprecated_entities.append(
HomeeSensor(attribute, config_entry, description)
)
if entity_used_in(hass, entity_id):
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{entity_uid}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"name": str(
entity_entry.name or entity_entry.original_name
),
"entity": entity_id,
},
)
return deprecated_entities
async def add_sensor_entities(
config_entry: HomeeConfigEntry,
@@ -362,19 +302,13 @@ async def async_setup_entry(
)
# Node attributes that are sensors.
for attribute in node.attributes:
if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
entities.extend(
add_deprecated_entity(
attribute, SENSOR_DESCRIPTIONS[attribute.type]
)
)
elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
entities.append(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
)
entities.extend(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
for attribute in node.attributes
if attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable
)
if entities:
async_add_entities(entities)

View File

@@ -495,11 +495,5 @@
"invalid_preset_mode": {
"message": "Invalid preset mode: {preset_mode}. Turning on is only supported with preset mode 'Manual'."
}
},
"issues": {
"deprecated_entity": {
"description": "The Homee entity `{entity}` is deprecated and will be removed in release 2025.12.\nThe valve is available directly in the respective climate entity.\nPlease update your automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
"title": "The Homee {name} entity is deprecated"
}
}
}

View File

@@ -59,7 +59,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
"""Representation of a binary HomeMatic device."""
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if switch is on."""
if not self.available:
return False
@@ -73,7 +73,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
return BinarySensorDeviceClass.MOTION
return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__)
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:
@@ -86,11 +86,11 @@ class HMBatterySensor(HMDevice, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.BATTERY
@property
def is_on(self):
def is_on(self) -> bool:
"""Return True if battery is low."""
return bool(self._hm_get_state())
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
# Add state to data struct
if self._state:

View File

@@ -178,7 +178,7 @@ class HMThermostat(HMDevice, ClimateEntity):
# Homematic
return self._data.get("CONTROL_MODE")
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dict (self._data) from the Homematic metadata."""
self._state = next(iter(self._hmdevice.WRITENODE.keys()))
self._data[self._state] = None

View File

@@ -78,7 +78,7 @@ class HMCover(HMDevice, CoverEntity):
"""Stop the device if in motion."""
self._hmdevice.stop(self._channel)
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
self._state = "LEVEL"
self._data.update({self._state: None})
@@ -138,7 +138,7 @@ class HMGarage(HMCover):
"""Return whether the cover is closed."""
return self._hmdevice.is_closed(self._hm_get_state())
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
self._state = "DOOR_STATE"
self._data.update({self._state: None})

View File

@@ -204,7 +204,7 @@ class HMDevice(Entity):
self._init_data_struct()
@abstractmethod
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dictionary from the HomeMatic device metadata."""

View File

@@ -51,7 +51,7 @@ class HMLight(HMDevice, LightEntity):
_attr_max_color_temp_kelvin = 6500 # 153 Mireds
@property
def brightness(self):
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
# Is dimmer?
if self._state == "LEVEL":
@@ -59,7 +59,7 @@ class HMLight(HMDevice, LightEntity):
return None
@property
def is_on(self):
def is_on(self) -> bool:
"""Return true if light is on."""
try:
return self._hm_get_state() > 0
@@ -98,7 +98,7 @@ class HMLight(HMDevice, LightEntity):
return features
@property
def hs_color(self):
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
if ColorMode.HS not in self.supported_color_modes:
return None
@@ -116,14 +116,14 @@ class HMLight(HMDevice, LightEntity):
)
@property
def effect_list(self):
def effect_list(self) -> list[str] | None:
"""Return the list of supported effects."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
return self._hmdevice.get_effect_list()
@property
def effect(self):
def effect(self) -> str | None:
"""Return the current color change program of the light."""
if not self.supported_features & LightEntityFeature.EFFECT:
return None
@@ -166,7 +166,7 @@ class HMLight(HMDevice, LightEntity):
self._hmdevice.off(self._channel)
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dict (self._data) from the Homematic metadata."""
# Use LEVEL
self._state = "LEVEL"

View File

@@ -48,7 +48,7 @@ class HMLock(HMDevice, LockEntity):
"""Open the door latch."""
self._hmdevice.open()
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})

View File

@@ -339,7 +339,7 @@ class HMSensor(HMDevice, SensorEntity):
# No cast, return original value
return self._hm_get_state()
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate a data dictionary (self._data) from metadata."""
if self._state:
self._data.update({self._state: None})

View File

@@ -35,7 +35,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Representation of a HomeMatic switch."""
@property
def is_on(self):
def is_on(self) -> bool:
"""Return True if switch is on."""
try:
return self._hm_get_state() > 0
@@ -43,7 +43,7 @@ class HMSwitch(HMDevice, SwitchEntity):
return False
@property
def today_energy_kwh(self):
def today_energy_kwh(self) -> float | None:
"""Return the current power usage in kWh."""
if "ENERGY_COUNTER" in self._data:
try:
@@ -61,7 +61,7 @@ class HMSwitch(HMDevice, SwitchEntity):
"""Turn the switch off."""
self._hmdevice.off(self._channel)
def _init_data_struct(self):
def _init_data_struct(self) -> None:
"""Generate the data dictionary (self._data) from metadata."""
self._state = "STATE"
self._data.update({self._state: None})

View File

@@ -181,7 +181,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass
)
@property
def state_class(self):
def state_class(self) -> SensorStateClass:
"""Return the state class of this entity, from STATE_CLASSES, if any."""
return SensorStateClass.MEASUREMENT

View File

@@ -28,6 +28,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN, UPDATE_INTERVAL
from .entity import AqualinkEntity
@@ -66,7 +67,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
aqualink = AqualinkClient(username, password, httpx_client=get_async_client(hass))
aqualink = AqualinkClient(
username,
password,
httpx_client=get_async_client(hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2),
)
try:
await aqualink.login()
except AqualinkServiceException as login_exception:

View File

@@ -15,6 +15,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
from .const import DOMAIN
@@ -36,7 +37,11 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
try:
async with AqualinkClient(
username, password, httpx_client=get_async_client(self.hass)
username,
password,
httpx_client=get_async_client(
self.hass, alpn_protocols=SSL_ALPN_HTTP11_HTTP2
),
):
pass
except AqualinkServiceUnauthorizedException:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.2.0"]
"requirements": ["pyicloud==2.3.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==1.6.0"]
"requirements": ["imgw_pib==2.0.1"]
}

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