Compare commits

..

133 Commits

Author SHA1 Message Date
Jan Čermák
8df4152d4e Disable unnecessary parts to test image build 2026-02-24 17:18:39 +01:00
Jan Čermák
e6ed0b5d14 Use native ARM runner for builder action, update to builder 2026.02.1
Since 2026.02.0 the builder has sha-pinning fixed, so we can also get rid of
the Zizmor error suppression.

Builder changes:
* https://github.com/home-assistant/builder/releases/tag/2026.02.0
* https://github.com/home-assistant/builder/releases/tag/2026.02.1
2026-02-24 16:15:30 +01:00
On Freund
7adfb0a40b Add bus support to MTA integration (#163220)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 16:11:13 +01:00
Zoltán Farkasdi
b4705e4a45 Fix flaky netatmo test (#163941) 2026-02-24 16:02:00 +01:00
Tom
a0176d18cf Add DHCP ip_addresses update to airOS (#163936) 2026-02-24 15:36:52 +01:00
Kevin Stillhammer
5543107f6c Allow to disable seconds in DurationSelector (#163803) 2026-02-24 15:11:26 +01:00
Klaas Schoute
6dc8840932 Rename Powerfox integration to Powerfox Cloud (#163723) 2026-02-24 14:42:43 +01:00
Stefan Agner
76902aa7fa Avoid adding Content-Type to non-body responses (#163885) 2026-02-24 14:31:04 +01:00
Erwin Douna
07b9877f64 Add button platform to Proxmox (#163791)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 14:24:20 +01:00
Erik Montnemery
40e2f79e60 Add support for reading backups using securetar v3 (#163920) 2026-02-24 14:23:00 +01:00
Christopher Fenner
aa707fcf41 Add gateway discovery via USB for EnOcean integration (#162756)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 11:58:01 +01:00
Willem-Jan van Rootselaar
4b53bc243d Add energy sensor to bsblan (#163879) 2026-02-24 11:56:27 +01:00
Robert Resch
220e94d029 Fix nightlies by reverting the builder to a version instead of a sha (#163935) 2026-02-24 11:48:19 +01:00
Erik Montnemery
b1f943ccda Replace discovery with user flow in Philips Hue BLE (#163924) 2026-02-24 11:06:31 +01:00
Brett Adams
e37d84049a Update Splunk integration to bronze quality scale (#163616)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-24 10:56:05 +01:00
Marc Mueller
209473e376 Remove myself as codeowner for fritzbox_callmonitor (#163927) 2026-02-24 10:45:58 +01:00
MoonDevLT
334c3af448 Bump lunatone-rest-api-client to 0.7.0 (#163594) 2026-02-24 10:10:04 +01:00
hanwg
5560139d24 Clean up duplicated code in Telegram bot (#163917) 2026-02-24 10:04:21 +01:00
Erik Montnemery
d4dec5d1d3 Improve backup_restore tests (#163921) 2026-02-24 10:03:42 +01:00
J. Nick Koston
6cb63a60bc Skip unknown entity types in ESPHome integration (#163887) 2026-02-24 08:48:27 +01:00
Franck Nijhof
991301e79e Merge branch 'master' into dev 2026-02-24 07:07:39 +00:00
andreimoraru
06e2b4633a Bump yt-dlp to 2026.2.21 (#163916) 2026-02-24 07:30:54 +01:00
Manu
048d8d217c Update strings in ntfy integration (#163912) 2026-02-24 06:24:18 +01:00
Kyle Johnson
3693bc5878 Make Google Assistant fan speed percent and step speeds mutually exclusive (#162770) 2026-02-23 22:26:09 +00:00
Denis Shulyaka
af9ea5ea7a Bump anthropic to 0.83.0 (#163899) 2026-02-23 21:43:07 +00:00
Robert Resch
977d29956b Add clean_area support for Ecovacs mqtt vacuums (#163580) 2026-02-23 22:42:25 +01:00
Jamie Magee
fc9bdb3cb1 Bring aladdin_connect to Bronze quality scale (#163221)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-23 22:16:51 +01:00
Erwin Douna
bb1956c738 Portainer Platinum score (#163898) 2026-02-23 22:15:59 +01:00
J. Nick Koston
9212279c2c Bump aioesphomeapi 44.1.0 (#163894) 2026-02-23 22:14:40 +01:00
Denis Shulyaka
7e162cfda2 Update Anthropic models (#163897) 2026-02-23 22:13:31 +01:00
Tom Matheussen
5611b4564f Add debounce to Satel Integra alarm panel state (#163602) 2026-02-23 21:57:39 +01:00
Manu
1a16674f86 Update quality scale of Xbox integration to platinum 🏆️ (#155577)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:56:05 +01:00
Paul Tarjan
bae4de3753 Add Hikvision integration quality scale (#159252)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:53:22 +01:00
mettolen
8f2bfa1bb0 Add select entities to Liebherr integration (#163581) 2026-02-23 21:52:50 +01:00
Manu
fb118ed516 Add support for action buttons to ntfy integration (#152014) 2026-02-23 21:46:00 +01:00
Markus Adrario
bea84151b1 homee: add one-button-remote to event platform (#163690) 2026-02-23 21:42:08 +01:00
Markus Adrario
d581d65c8b Add handling of 2 IP addresses to homee (#162731)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:36:49 +01:00
Erwin Douna
bc1837d09d Portainer gold standard review (#155231)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 21:34:06 +01:00
Daniel Hjelseth Høyer
9cc3c850aa Homevolt switch platform (#163415)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2026-02-23 21:16:43 +01:00
Markus
8927960fca fix(snapcast): do not crash when stream is not found (#162439)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-23 21:09:14 +01:00
Erwin Douna
49b8232260 Add stale device removal to portainer (#160017)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 21:05:52 +01:00
Barry vd. Heuvel
1d5e8a9e5a Weheat energy logs update (#163621)
Co-authored-by: Jesper Raemaekers <jesper.raemaekers@wefabricate.com>
2026-02-23 21:00:35 +01:00
dvdinth
501e095578 Add IntelliClima Select platform (#163637)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-23 20:41:41 +01:00
Jeef
dc5eab6810 Allow support of Graph QL 4.0 / Bump pytibber 0.36.0 (#163305)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-23 20:41:05 +01:00
Franck Nijhof
9c640fe0fa 2026.2.3 (#163683) 2026-02-20 21:43:32 +01:00
Sid
62145e5f9e Bump eheimdigital to 1.6.0 (#161961) 2026-02-20 19:51:10 +00:00
Franck Nijhof
c0fc414bb9 Fix nrgkick tests for rc 2026-02-20 19:49:27 +00:00
Franck Nijhof
69411a05ff Bump version to 2026.2.3 2026-02-20 19:39:05 +00:00
Marc Mueller
06c9ec861d Fix hassfest requirements check (#163681) 2026-02-20 19:38:58 +00:00
Joost Lekkerkerker
946df1755f Bump pySmartThings to 3.5.3 (#163375)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-20 19:38:56 +00:00
Thomas Sejr Madsen
d0678e0641 Fix touchline_sl zone availability when alarm state is set (#163338) 2026-02-20 19:38:55 +00:00
Allen Porter
ec56f183da Bump pyrainbird to 6.0.5 (#163333) 2026-02-20 19:38:53 +00:00
Åke Strandberg
033005e0de Add Miele dishwasher program code (#163308) 2026-02-20 19:38:52 +00:00
Andreas Jakl
91f9f5a826 NRGkick: do not update vehicle connected timestamp when vehicle is not connected (#163292) 2026-02-20 19:38:51 +00:00
David Recordon
ac4fcab827 Fix Control4 HVAC action mapping for multi-stage and idle states (#163222) 2026-02-20 19:38:49 +00:00
Allen Porter
d0eea77178 Fix remote calendar event handling of events within the same update period (#163186)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 19:38:48 +00:00
Markus Adrario
fb38fa3844 Add Lux to homee units (#163180) 2026-02-20 19:38:47 +00:00
Allen Porter
440efb953e Bump ical to 13.2.0 (#163123) 2026-02-20 19:38:45 +00:00
Manu
7ce47cca0d Fix blocking call in Xbox config flow (#163122) 2026-02-20 19:38:44 +00:00
Andre Lengwenus
a5f607bb91 Bump pypck to 0.9.11 (#163043) 2026-02-20 19:38:42 +00:00
Andre Lengwenus
b03043aa6f Bump pypck to 0.9.10 (#162333) 2026-02-20 19:38:41 +00:00
Robert Resch
0f3c7ca277 Block redirect to localhost (#162941) 2026-02-20 19:37:03 +00:00
Martin Hjelmare
3abf7c22f3 Fix Z-Wave climate set preset (#162728) 2026-02-20 19:37:01 +00:00
hbludworth
292e1de126 Show progress indicator during backup stage of Core/App update (#162683) 2026-02-20 19:37:00 +00:00
Christian Lackas
2d776a8193 Fix HomematicIP entity recovery after access point cloud reconnect (#162575) 2026-02-20 19:36:58 +00:00
Sid
039bbbb48c Fix dynamic entity creation in eheimdigital (#161155) 2026-02-20 19:36:56 +00:00
Luke Lashley
ad5565df95 Add the ability to select region for Roborock (#160898) 2026-02-20 19:36:55 +00:00
Franck Nijhof
3e6bc29a6a 2026.2.2 (#162950) 2026-02-13 21:05:06 +01:00
Franck Nijhof
ec8067a5a8 Bump version to 2026.2.2 2026-02-13 19:25:16 +00:00
Josef Zweck
6f47716d0a Log remaining token duration in onedrive (#162933) 2026-02-13 19:24:25 +00:00
puddly
efba5c6bcc Bump ZHA to 0.0.90 (#162894) 2026-02-13 19:24:24 +00:00
Sammy [Andrei Marinache]
d10e78079f Add Miele TQ1000WP tumble dryer programs and program phases (#162871)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Åke Strandberg <ake@strandberg.eu>
2026-02-13 19:24:23 +00:00
Jon Seager
6d4581580f Bump pytouchlinesl to 0.6.0 (#162856) 2026-02-13 19:24:21 +00:00
Yoshi Walsh
0d9a41a540 Bump pydaikin to 2.17.2 (#162846) 2026-02-13 19:24:20 +00:00
Vicx
cd69e6db73 Bump slixmpp to 1.13.2 (#162837) 2026-02-13 19:24:19 +00:00
Xitee
1320367d0d Filter out transient zero values from qBittorrent alltime stats (#162821)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:24:18 +00:00
Joost Lekkerkerker
dfa4698887 Bump pySmartThings to 3.5.2 (#162809)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-13 19:24:17 +00:00
Robert Resch
b426115de7 Bump cryptography to 46.0.5 (#162783) 2026-02-13 19:24:15 +00:00
hanwg
fb79fa37f8 Fix bug in edit_message_media action for Telegram bot (#162762) 2026-02-13 19:24:14 +00:00
Simone Chemelli
6a5f7bf424 Fix image platform state for Vodafone Station (#162747)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-13 19:24:13 +00:00
Simone Chemelli
142ca6dec1 Fix alarm refresh warning for Comelit SimpleHome (#162710) 2026-02-13 19:24:12 +00:00
epenet
0f986c24d0 Fix unavailable status in Tuya (#162709) 2026-02-13 19:24:11 +00:00
Josef Zweck
01f2b7b6f6 Bump onedrive-personal-sdk to 0.1.2 (#162689) 2026-02-13 19:24:09 +00:00
Michael
b9469027f5 Fix handling when FRITZ!Box reboots in FRITZ!Box Tools (#162679) 2026-02-13 19:24:08 +00:00
Tomás Correia
fbb94af748 fix to cloudflare r2 setup screen info (#162677) 2026-02-13 19:24:07 +00:00
Michael
148bdf6e3a Fix handling when FRITZ!Box reboots in FRITZ!Smarthome (#162676) 2026-02-13 19:24:05 +00:00
starkillerOG
91999f8871 Bump reolink-aio to 0.19.0 (#162672) 2026-02-13 19:24:04 +00:00
Jeef
aecca4eb99 Bump intellifire4py to 4.3.1 (#162659) 2026-02-13 19:24:03 +00:00
Allen Porter
bf8aa49bae Improve MCP SSE fallback error handling (#162655) 2026-02-13 19:24:02 +00:00
Joost Lekkerkerker
4423425683 Pin setuptools to 81.0.0 (#162589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:24:01 +00:00
Aaron Godfrey
44202da53d Increase max tasks retrieved per page to prevent timeout (#162587) 2026-02-13 19:23:59 +00:00
Thomas55555
9f7dfb72c4 Bump aioautomower to 2.7.3 (#162583)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:58 +00:00
Michael
de07a69e4f Bump aioimmich to 0.12.0 (#162573)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-13 19:23:57 +00:00
Maikel Punie
bbf4c38115 migrate velbus config entries (#162565) 2026-02-13 19:23:56 +00:00
ElCruncharino
e1bb5d52ef Add timeout to B2 metadata downloads to prevent backup hang (#162562) 2026-02-13 19:23:54 +00:00
hanwg
eb64b6bdee Fix config flow bug for Telegram bot (#162555) 2026-02-13 19:23:53 +00:00
Andrea Turri
ecb288b735 Add new Miele mappings (#162544) 2026-02-13 19:23:52 +00:00
Norbert Rittel
a419c9c420 Sentence-case "speech-to-text" in google_cloud (#162534) 2026-02-13 19:23:51 +00:00
Brett Adams
dd29133324 Fix Tesla Fleet partner registration to use all regions (#162525)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:23:50 +00:00
Allen Porter
90f22ea516 Bump grpc to 1.78.0 (#162520) 2026-02-13 19:23:48 +00:00
Peter Grauvogel
9db1428265 Fix Green Planet Energy price unit conversion (#162511) 2026-02-13 19:23:47 +00:00
Denis Shulyaka
a696b05b0d Fix JSON serialization of time objects in Cloud conversation tool results (#162506) 2026-02-13 19:23:46 +00:00
Denis Shulyaka
77ddb63b73 Fix JSON serialization of time objects in Open Router tool results (#162505) 2026-02-13 19:23:44 +00:00
Denis Shulyaka
4180a6e176 Fix JSON serialization of time objects in Ollama tool results (#162502)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-13 19:23:43 +00:00
Denis Shulyaka
6d74c912d2 Fix JSON serialization of datetime objects in Google Generative AI tool results (#162495) 2026-02-13 19:23:42 +00:00
Denis Shulyaka
8a01dfcc00 Fix JSON serialization of time objects in OpenAI tool results (#162490) 2026-02-13 19:23:40 +00:00
Brett Adams
9722898dc6 Fix device_class of backup reserve sensor in Tessie (#162459) 2026-02-13 19:23:39 +00:00
Brett Adams
7438c71fcb Fix device_class of backup reserve sensor in teslemetry (#162458) 2026-02-13 19:23:38 +00:00
Christian Lackas
0b5e55b923 Fix absolute humidity sensor on HmIP-WGT glass thermostats (#162455) 2026-02-13 19:23:37 +00:00
ElCruncharino
61ed959e8e Fix AsyncIteratorReader blocking after stream exhaustion (#161731) 2026-02-13 19:17:20 +00:00
Jaap Pieroen
3989532465 Bump essent-dynamic-pricing to 0.3.1 (#160958)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-13 19:17:18 +00:00
Franck Nijhof
28027ddca4 2026.2.1 (#162450) 2026-02-06 22:44:07 +01:00
Franck Nijhof
fe0d7b3cca Bump version to 2026.2.1 2026-02-06 20:49:26 +00:00
jameson_uk
0dcc4e9527 dep: bump aioamazondevices to 11.1.3 (#162437) 2026-02-06 20:47:38 +00:00
Artur Pragacz
b13b189703 Make bad entity ID detection more lenient (#162425) 2026-02-06 20:47:37 +00:00
epenet
150829f599 Fix invalid yardian snaphots (#162422) 2026-02-06 20:47:36 +00:00
Joost Lekkerkerker
57dd9d9c23 Remove double unit of measurement for yardian (#162412) 2026-02-06 20:47:34 +00:00
Sab44
e2056cb12c Bump librehardwaremonitor-api to version 1.9.1 (#162409) 2026-02-06 20:47:33 +00:00
Joost Lekkerkerker
fa2c8992cf Remove entity id overwrite for ambient station (#162403) 2026-02-06 20:47:32 +00:00
Matt Zimmerman
ddf5c7fe3a Add missing config flow strings to SmartTub (#162375)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:31 +00:00
Matt Zimmerman
7034ed6d3f Bump python-smarttub to 0.0.47 (#162367)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:29 +00:00
Aaron Godfrey
9015b53c1b Fix conversion of data for todo.* actions (#162366) 2026-02-06 20:47:28 +00:00
Jordan Harvey
1cfa6561f7 Update pynintendoparental requirement to version 2.3.2.1 (#162362) 2026-02-06 20:47:27 +00:00
Shay Levy
eead02dcca Fix Shelly Linkedgo Thermostat status update (#162339) 2026-02-06 20:47:26 +00:00
Arie Catsman
456e51a221 Bump pyenphase to 2.4.5 (#162324) 2026-02-06 20:47:25 +00:00
Luo Chen
5d984ce186 Fix unicode escaping in MCP server tool response (#162319)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-06 20:47:24 +00:00
Oliver
61f45489ac Add mapping for stopped state to denonavr media player (#162283) 2026-02-06 20:47:23 +00:00
Tomás Correia
f72c643b38 Fix multipart upload to use consistent part sizes for R2/S3 (#162278) 2026-02-06 20:47:22 +00:00
Oliver
27bc26e886 Bump denonavr to 1.3.2 (#162271) 2026-02-06 20:47:20 +00:00
Thomas55555
0e9f03cbc1 Bump google_air_quality_api to 3.0.1 (#162233) 2026-02-06 20:47:19 +00:00
David Bonnes
9480c33fb0 Bump evohome-async to 1.1.3 (#162232) 2026-02-06 20:47:18 +00:00
Jonathan
3e6b8663e8 Fix device_class of backup reserve sensor (#161178) 2026-02-06 20:47:17 +00:00
epenet
1c69a83793 Fix redundant off preset in Tuya climate (#161040) 2026-02-06 20:47:16 +00:00
191 changed files with 10023 additions and 2002 deletions

View File

@@ -34,7 +34,6 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/**
- homeassistant/components/image/**
- homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/**
- homeassistant/components/light/**
- homeassistant/components/lock/**

View File

@@ -57,10 +57,10 @@ jobs:
with:
type: ${{ env.BUILD_TYPE }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
with:
ignore-dev: true
# - name: Verify version
# uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses]
# with:
# ignore-dev: true
- name: Fail if translations files are checked in
run: |
@@ -272,7 +272,7 @@ jobs:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
@@ -294,6 +294,21 @@ jobs:
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -321,286 +336,288 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@21bc64d76dad7a5184c67826aab41c6b6f89023a # 2025.11.0
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--test \
--target /data/machine \
--cosign \
--machine "${{ needs.init.outputs.version }}=${{ matrix.machine }}"
publish_ha:
name: Publish version files
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
with:
name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }}
- name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: ${{ needs.init.outputs.channel }}
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
- name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
with:
key: "homeassistant[]"
key-description: "Home Assistant Core"
version: ${{ needs.init.outputs.version }}
channel: beta
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
id-token: write # For cosign signing
strategy:
fail-fast: false
matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Verify architecture image signatures
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Verifying ${arch} image signature..."
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
done
echo "✓ All images verified successfully"
# Generate all Docker tags based on version string
# Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# Examples:
# 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
tags: |
type=raw,value=${{ needs.init.outputs.version }},priority=9999
type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: |
# Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do
if docker buildx imagetools create \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
done
- name: Create and push multi-arch manifests
shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
# Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
ARCH_IMAGES=()
for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
done
# Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}"
for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}")
done
# Create manifest with ALL tags in a single operation (much faster!)
echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
# Sign each tag separately (signing requires individual tag names)
echo "Signing all tags..."
for tag in "${TAGS[@]}"; do
echo "Signing ${tag}"
cosign sign --yes "${tag}"
done
echo "All manifests created and signed successfully"
build_python:
name: Build PyPi package
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
id-token: write # For PyPI trusted publishing
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: translations
- name: Extract translations
run: |
tar xvf translations.tar.gz
rm translations.tar.gz
- name: Build package
shell: bash
run: |
# Remove dist, build, and homeassistant.egg-info
# when build locally for testing!
pip install build
python -m build
- name: Upload package to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
hassfest-image:
name: Build and test hassfest image
runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
attestations: write # For build provenance attestation
id-token: write # For build provenance attestation
needs: ["init"]
if: github.repository_owner == 'home-assistant'
env:
HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
load: true
tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
push: true
tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# publish_ha:
# name: Publish version files
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_machine"]
# runs-on: ubuntu-latest
# permissions:
# contents: read
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Initialize git
# uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses]
# with:
# name: ${{ secrets.GIT_NAME }}
# email: ${{ secrets.GIT_EMAIL }}
# token: ${{ secrets.GIT_TOKEN }}
#
# - name: Update version file
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: ${{ needs.init.outputs.channel }}
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
#
# - name: Update version file (stable -> beta)
# if: needs.init.outputs.channel == 'stable'
# uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses]
# with:
# key: "homeassistant[]"
# key-description: "Home Assistant Core"
# version: ${{ needs.init.outputs.version }}
# channel: beta
# exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
#
# publish_container:
# name: Publish meta container for ${{ matrix.registry }}
# environment: ${{ needs.init.outputs.channel }}
# if: github.repository_owner == 'home-assistant'
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# id-token: write # For cosign signing
# strategy:
# fail-fast: false
# matrix:
# registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
# steps:
# - name: Install Cosign
# uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# with:
# cosign-release: "v2.5.3"
#
# - name: Login to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# username: ${{ secrets.DOCKERHUB_USERNAME }}
# password: ${{ secrets.DOCKERHUB_TOKEN }}
#
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: Verify architecture image signatures
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Verifying ${arch} image signature..."
# cosign verify \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# --certificate-identity-regexp https://github.com/home-assistant/core/.* \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"
# done
# echo "✓ All images verified successfully"
#
# # Generate all Docker tags based on version string
# # Version format: YYYY.MM.PATCH, YYYY.MM.PATCHbN (beta), or YYYY.MM.PATCH.devYYYYMMDDHHMM (dev)
# # Examples:
# # 2025.12.1 (stable) -> tags: 2025.12.1, 2025.12, stable, latest, beta, rc
# # 2025.12.0b3 (beta) -> tags: 2025.12.0b3, beta, rc
# # 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
# - name: Generate Docker metadata
# id: meta
# uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
# with:
# images: ${{ matrix.registry }}/home-assistant
# sep-tags: ","
# tags: |
# type=raw,value=${{ needs.init.outputs.version }},priority=9999
# type=raw,value=dev,enable=${{ contains(needs.init.outputs.version, 'd') }}
# type=raw,value=beta,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=rc,enable=${{ !contains(needs.init.outputs.version, 'd') }}
# type=raw,value=stable,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=raw,value=latest,enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
# type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
#
# - name: Copy architecture images to DockerHub
# if: matrix.registry == 'docker.io/homeassistant'
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# VERSION: ${{ needs.init.outputs.version }}
# run: |
# # Use imagetools to copy image blobs directly between registries
# # This preserves provenance/attestations and seems to be much faster than pull/push
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# for arch in $ARCHS; do
# echo "Copying ${arch} image to DockerHub..."
# for attempt in 1 2 3; do
# if docker buildx imagetools create \
# --tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \
# "ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then
# break
# fi
# echo "Attempt ${attempt} failed, retrying in 10 seconds..."
# sleep 10
# if [ "${attempt}" -eq 3 ]; then
# echo "Failed after 3 attempts"
# exit 1
# fi
# done
# cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
# done
#
# - name: Create and push multi-arch manifests
# shell: bash
# env:
# ARCHITECTURES: ${{ needs.init.outputs.architectures }}
# REGISTRY: ${{ matrix.registry }}
# VERSION: ${{ needs.init.outputs.version }}
# META_TAGS: ${{ steps.meta.outputs.tags }}
# run: |
# # Build list of architecture images dynamically
# ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]')
# ARCH_IMAGES=()
# for arch in $ARCHS; do
# ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}")
# done
#
# # Build list of all tags for single manifest creation
# # Note: Using sep-tags=',' in metadata-action for easier parsing
# TAG_ARGS=()
# IFS=',' read -ra TAGS <<< "${META_TAGS}"
# for tag in "${TAGS[@]}"; do
# TAG_ARGS+=("--tag" "${tag}")
# done
#
# # Create manifest with ALL tags in a single operation (much faster!)
# echo "Creating multi-arch manifest with tags: ${TAGS[*]}"
# docker buildx imagetools create "${TAG_ARGS[@]}" "${ARCH_IMAGES[@]}"
#
# # Sign each tag separately (signing requires individual tag names)
# echo "Signing all tags..."
# for tag in "${TAGS[@]}"; do
# echo "Signing ${tag}"
# cosign sign --yes "${tag}"
# done
#
# echo "All manifests created and signed successfully"
#
# build_python:
# name: Build PyPi package
# environment: ${{ needs.init.outputs.channel }}
# needs: ["init", "build_base"]
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# id-token: write # For PyPI trusted publishing
# if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
# steps:
# - name: Checkout the repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Set up Python ${{ env.DEFAULT_PYTHON }}
# uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
# with:
# python-version: ${{ env.DEFAULT_PYTHON }}
#
# - name: Download translations
# uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
# with:
# name: translations
#
# - name: Extract translations
# run: |
# tar xvf translations.tar.gz
# rm translations.tar.gz
#
# - name: Build package
# shell: bash
# run: |
# # Remove dist, build, and homeassistant.egg-info
# # when build locally for testing!
# pip install build
# python -m build
#
# - name: Upload package to PyPI
# uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
# with:
# skip-existing: true
#
# hassfest-image:
# name: Build and test hassfest image
# runs-on: ubuntu-latest
# permissions:
# contents: read # To check out the repository
# packages: write # To push to GHCR
# attestations: write # For build provenance attestation
# id-token: write # For build provenance attestation
# needs: ["init"]
# if: github.repository_owner == 'home-assistant'
# env:
# HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest
# HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
# steps:
# - name: Checkout repository
# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# with:
# persist-credentials: false
#
# - name: Login to GitHub Container Registry
# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
# with:
# registry: ghcr.io
# username: ${{ github.repository_owner }}
# password: ${{ secrets.GITHUB_TOKEN }}
#
# - name: Build Docker image
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# load: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }}
#
# - name: Run hassfest against core
# run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace
#
# - name: Push Docker image
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# id: push
# uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
# with:
# context: . # So action will not pull the repository again
# file: ./script/hassfest/docker/Dockerfile
# push: true
# tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest
#
# - name: Generate artifact attestation
# if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
# uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0
# with:
# subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true

View File

@@ -289,7 +289,6 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
homeassistant.components.input_select.*
homeassistant.components.input_text.*

4
CODEOWNERS generated
View File

@@ -555,8 +555,6 @@ build.json @home-assistant/supervisor
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
/tests/components/fritzbox_callmonitor/ @cdce8p
/homeassistant/components/fronius/ @farmio
/tests/components/fronius/ @farmio
/homeassistant/components/frontend/ @home-assistant/frontend
@@ -794,8 +792,6 @@ build.json @home-assistant/supervisor
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
/tests/components/influxdb/ @mdegat01 @Robbie1221
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core

View File

@@ -34,11 +34,13 @@ from homeassistant.const import (
)
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
DEFAULT_SSL,
@@ -392,6 +394,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
except asyncio.CancelledError:
pass
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Automatically handle a DHCP discovered IP change."""
ip_address = discovery_info.ip
# python-airos defaults to upper for derived mac_address
normalized_mac = format_mac(discovery_info.macaddress).upper()
await self.async_set_unique_id(normalized_mac)
self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address})
return self.async_abort(reason="unreachable")
async def async_step_discovery_no_devices(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -3,6 +3,7 @@
"name": "Ubiquiti airOS",
"codeowners": ["@CoMPaTech"],
"config_flow": true,
"dhcp": [{ "registered_devices": true }],
"documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling",

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import aiohttp
from genie_partner_sdk.client import AladdinConnectClient
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
@@ -31,11 +33,27 @@ async def async_setup_entry(
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
client = AladdinConnectClient(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session)
)
doors = await client.get_doors()
try:
doors = await client.get_doors()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(err) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = {
door.unique_id: AladdinConnectCoordinator(hass, entry, client, door)

View File

@@ -11,6 +11,18 @@ API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1"
API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3"
class AsyncConfigFlowAuth(Auth):
"""Provide Aladdin Connect Genie authentication for config flow validation."""
def __init__(self, websession: ClientSession, access_token: str) -> None:
"""Initialize Aladdin Connect Genie auth."""
super().__init__(websession, API_URL, access_token, API_KEY)
async def async_get_access_token(self) -> str:
"""Return the access token."""
return self.access_token
class AsyncConfigEntryAuth(Auth):
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""

View File

@@ -4,12 +4,14 @@ from collections.abc import Mapping
import logging
from typing import Any
from genie_partner_sdk.client import AladdinConnectClient
import jwt
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from .api import AsyncConfigFlowAuth
from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN
@@ -52,11 +54,25 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an oauth config entry or update existing entry for reauth."""
# Extract the user ID from the JWT token's 'sub' field
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
try:
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
user_id = token["sub"]
except jwt.DecodeError, KeyError:
return self.async_abort(reason="oauth_error")
client = AladdinConnectClient(
AsyncConfigFlowAuth(
aiohttp_client.async_get_clientsession(self.hass),
data["token"]["access_token"],
)
)
user_id = token["sub"]
try:
await client.get_doors()
except Exception: # noqa: BLE001
return self.async_abort(reason="cannot_connect")
await self.async_set_unique_id(user_id)
if self.source == SOURCE_REAUTH:

View File

@@ -7,39 +7,31 @@ rules:
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: todo
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any service actions.
docs-high-level-description: done
docs-installation-instructions:
status: todo
comment: Documentation needs to be created.
docs-removal-instructions:
status: todo
comment: Documentation needs to be created.
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to external events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure:
status: todo
comment: Config flow does not currently test connection during setup.
test-before-setup: todo
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: todo
comment: Documentation needs to be created.
docs-installation-parameters:
status: todo
comment: Documentation needs to be created.
status: exempt
comment: Integration does not have an options flow.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
@@ -52,29 +44,17 @@ rules:
# Gold
devices: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update:
status: todo
comment: Documentation needs to be created.
docs-examples:
status: todo
comment: Documentation needs to be created.
docs-known-limitations:
status: todo
comment: Documentation needs to be created.
docs-supported-devices:
status: todo
comment: Documentation needs to be created.
docs-supported-functions:
status: todo
comment: Documentation needs to be created.
docs-troubleshooting:
status: todo
comment: Documentation needs to be created.
docs-use-cases:
status: todo
comment: Documentation needs to be created.
discovery: done
discovery-update-info:
status: exempt
comment: Integration connects via the cloud and not locally.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
@@ -86,7 +66,7 @@ rules:
repair-issues: todo
stale-devices:
status: todo
comment: Stale devices can be done dynamically
comment: We can automatically remove removed devices
# Platinum
async-dependency: todo

View File

@@ -4,6 +4,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml.",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",

View File

@@ -112,19 +112,12 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in (
"claude-3-haiku-20240307",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
)
if model_info.id != "claude-3-haiku-20240307"
and model_info.id[-2:-1] != "-"
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
if model_alias.endswith(("haiku", "opus", "sonnet")):
model_alias += "-latest"
model_options.append(
SelectOptionDict(
label=model_info.display_name,

View File

@@ -37,8 +37,6 @@ DEFAULT = {
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-5", # Both sonnet and haiku
"claude-3-opus",
"claude-3-haiku",
]
@@ -51,7 +49,7 @@ NON_ADAPTIVE_THINKING_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3",
"claude-3-haiku",
]
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
@@ -60,19 +58,13 @@ UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3",
"claude-3-haiku",
]
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
"claude-3-opus",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
]
DEPRECATED_MODELS = [
"claude-3-5-haiku",
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-opus",
"claude-3",
]

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["anthropic==0.78.0"]
"requirements": ["anthropic==0.83.0"]
}

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
import voluptuous as vol
@@ -19,7 +19,7 @@ from homeassistant.helpers.selector import (
)
from .config_flow import get_model_list
from .const import CONF_CHAT_MODEL, DEFAULT, DEPRECATED_MODELS, DOMAIN
from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN
if TYPE_CHECKING:
from . import AnthropicConfigEntry
@@ -67,13 +67,23 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._model_list_cache[entry.entry_id] = model_list
if "opus" in model:
suggested_model = "claude-opus-4-5"
elif "haiku" in model:
suggested_model = "claude-haiku-4-5"
family = "claude-opus"
elif "sonnet" in model:
suggested_model = "claude-sonnet-4-5"
family = "claude-sonnet"
else:
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
family = "claude-haiku"
suggested_model = next(
(
model_option["value"]
for model_option in sorted(
(m for m in model_list if family in m["value"]),
key=lambda x: x["value"],
reverse=True,
)
),
vol.UNDEFINED,
)
schema = vol.Schema(
{

View File

@@ -16,6 +16,7 @@ from typing import IO, Any, cast
import aiohttp
from securetar import (
InvalidPasswordError,
SecureTarArchive,
SecureTarError,
SecureTarFile,
@@ -165,7 +166,7 @@ def validate_password(path: Path, password: str | None) -> bool:
):
# If we can read the tar file, the password is correct
return True
except tarfile.ReadError, SecureTarReadError:
except tarfile.ReadError, InvalidPasswordError, SecureTarReadError:
LOGGER.debug("Invalid password")
return False
except Exception: # noqa: BLE001
@@ -192,13 +193,14 @@ def validate_password_stream(
for obj in input_archive.tar:
if not obj.name.endswith((".tar", ".tgz", ".tar.gz")):
continue
with input_archive.extract_tar(obj) as decrypted:
if decrypted.plaintext_size is None:
raise UnsupportedSecureTarVersion
try:
try:
with input_archive.extract_tar(obj) as decrypted:
if decrypted.plaintext_size is None:
raise UnsupportedSecureTarVersion
decrypted.read(1) # Read a single byte to trigger the decryption
except SecureTarReadError as err:
raise IncorrectPassword from err
except (InvalidPasswordError, SecureTarReadError) as err:
raise IncorrectPassword from err
else:
return
raise BackupEmpty

View File

@@ -29,8 +29,13 @@ if TYPE_CHECKING:
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
STATE_INCLUDE = [
"current_temperature",
"target_temperature",
"hvac_mode",
"hvac_action",
]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature", "total_energy"]
DHW_STATE_INCLUDE = [
"operating_mode",
"nominal_setpoint",

View File

@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -58,6 +58,19 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
),
exists_fn=lambda data: data.sensor.outside_temperature is not None,
),
BSBLanSensorEntityDescription(
key="total_energy",
translation_key="total_energy",
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda data: (
data.sensor.total_energy.value
if data.sensor.total_energy is not None
else None
),
exists_fn=lambda data: data.sensor.total_energy is not None,
),
)

View File

@@ -66,6 +66,9 @@
},
"outside_temperature": {
"name": "Outside temperature"
},
"total_energy": {
"name": "Total energy"
}
}
},

View File

@@ -8,17 +8,24 @@ from typing import TYPE_CHECKING, Any
from deebot_client.capabilities import Capabilities, DeviceType
from deebot_client.device import Device
from deebot_client.events import FanSpeedEvent, RoomsEvent, StateEvent
from deebot_client.models import CleanAction, CleanMode, Room, State
from deebot_client.events import (
CachedMapInfoEvent,
FanSpeedEvent,
RoomsEvent,
StateEvent,
)
from deebot_client.events.map import Map
from deebot_client.models import CleanAction, CleanMode, State
import sucks
from homeassistant.components.vacuum import (
Segment,
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
@@ -29,6 +36,7 @@ from .entity import EcovacsEntity, EcovacsLegacyEntity
from .util import get_name_key
_LOGGER = logging.getLogger(__name__)
_SEGMENTS_SEPARATOR = "_"
ATTR_ERROR = "error"
@@ -218,7 +226,8 @@ class EcovacsVacuum(
"""Initialize the vacuum."""
super().__init__(device, device.capabilities)
self._rooms: list[Room] = []
self._room_event: RoomsEvent | None = None
self._maps: dict[str, Map] = {}
if fan_speed := self._capability.fan_speed:
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
@@ -226,14 +235,13 @@ class EcovacsVacuum(
get_name_key(level) for level in fan_speed.types
]
if self._capability.map and self._capability.clean.action.area:
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
async def async_added_to_hass(self) -> None:
"""Set up the event listeners now that hass is ready."""
await super().async_added_to_hass()
async def on_rooms(event: RoomsEvent) -> None:
self._rooms = event.rooms
self.async_write_ha_state()
async def on_status(event: StateEvent) -> None:
self._attr_activity = _STATE_TO_VACUUM_STATE[event.state]
self.async_write_ha_state()
@@ -249,8 +257,20 @@ class EcovacsVacuum(
self._subscribe(self._capability.fan_speed.event, on_fan_speed)
if map_caps := self._capability.map:
async def on_rooms(event: RoomsEvent) -> None:
self._room_event = event
self._check_segments_changed()
self.async_write_ha_state()
self._subscribe(map_caps.rooms.event, on_rooms)
async def on_map_info(event: CachedMapInfoEvent) -> None:
self._maps = {map_obj.id: map_obj for map_obj in event.maps}
self._check_segments_changed()
self._subscribe(map_caps.cached_info.event, on_map_info)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return entity specific state attributes.
@@ -259,7 +279,10 @@ class EcovacsVacuum(
is lowercase snake_case.
"""
rooms: dict[str, Any] = {}
for room in self._rooms:
if self._room_event is None:
return rooms
for room in self._room_event.rooms:
# convert room name to snake_case to meet the convention
room_name = slugify(room.name)
room_values = rooms.get(room_name)
@@ -374,3 +397,116 @@ class EcovacsVacuum(
)
return await self._device.execute_command(position_commands[0])
@callback
def _check_segments_changed(self) -> None:
"""Check if segments have changed and create repair issue."""
last_seen = self.last_seen_segments
if last_seen is None:
return
last_seen_ids = {seg.id for seg in last_seen}
current_ids = {seg.id for seg in self._get_segments()}
if current_ids != last_seen_ids:
self.async_create_segments_issue()
def _get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
last_seen = self.last_seen_segments or []
if self._room_event is None or not self._maps:
# If we don't have the necessary information to determine segments, return the last
# seen segments to avoid temporarily losing all segments until we get the necessary
# information, which could cause unnecessary issues to be created
return last_seen
map_id = self._room_event.map_id
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
return []
id_prefix = f"{map_id}{_SEGMENTS_SEPARATOR}"
other_map_ids = {
map_obj.id
for map_obj in self._maps.values()
if map_obj.id != self._room_event.map_id
}
# Include segments from the current map and any segments from other maps that were
# previously seen, as we want to continue showing segments from other maps for
# mapping purposes
segments = [
seg for seg in last_seen if _split_composite_id(seg.id)[0] in other_map_ids
]
segments.extend(
Segment(
id=f"{id_prefix}{room.id}",
name=room.name,
group=map_obj.name,
)
for room in self._room_event.rooms
)
return segments
async def async_get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned."""
return self._get_segments()
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Perform an area clean.
Only cleans segments from the currently selected map.
"""
if not self._maps:
_LOGGER.warning("No map information available, cannot clean segments")
return
valid_room_ids: list[int | float] = []
for composite_id in segment_ids:
map_id, segment_id = _split_composite_id(composite_id)
if (map_obj := self._maps.get(map_id)) is None:
_LOGGER.warning("Map ID %s not found in available maps", map_id)
continue
if not map_obj.using:
room_name = next(
(
segment.name
for segment in self.last_seen_segments or []
if segment.id == composite_id
),
"",
)
_LOGGER.warning(
'Map "%s" is not currently selected, skipping segment "%s" (%s)',
map_obj.name,
room_name,
segment_id,
)
continue
valid_room_ids.append(int(segment_id))
if not valid_room_ids:
_LOGGER.warning(
"No valid segments to clean after validation, skipping clean segments command"
)
return
if TYPE_CHECKING:
# Supported feature is only added if clean.action.area is not None
assert self._capability.clean.action.area is not None
await self._device.execute_command(
self._capability.clean.action.area(
CleanMode.SPOT_AREA,
valid_room_ids,
1,
)
)
@callback
def _split_composite_id(composite_id: str) -> tuple[str, str]:
"""Split a composite ID into its components."""
map_id, _, segment_id = composite_id.partition(_SEGMENTS_SEPARATOR)
return map_id, segment_id

View File

@@ -4,17 +4,23 @@ from typing import Any
import voluptuous as vol
from homeassistant.components import usb
from homeassistant.components.usb import (
human_readable_device_name,
usb_unique_id_from_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE
from homeassistant.const import ATTR_MANUFACTURER, CONF_DEVICE, CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import dongle
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER
from .const import DOMAIN, ERROR_INVALID_DONGLE_PATH, LOGGER, MANUFACTURER
MANUAL_SCHEMA = vol.Schema(
{
@@ -31,8 +37,48 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the EnOcean config flow."""
self.dongle_path = None
self.discovery_info = None
self.data: dict[str, Any] = {}
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle usb discovery."""
unique_id = usb_unique_id_from_service_info(discovery_info)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured(
updates={CONF_DEVICE: discovery_info.device}
)
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
)
self.data[CONF_DEVICE] = discovery_info.device
self.context["title_placeholders"] = {
CONF_NAME: human_readable_device_name(
discovery_info.device,
discovery_info.serial_number,
discovery_info.manufacturer,
discovery_info.description,
discovery_info.vid,
discovery_info.pid,
)
}
return await self.async_step_usb_confirm()
async def async_step_usb_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle USB Discovery confirmation."""
if user_input is not None:
return await self.async_step_manual({CONF_DEVICE: self.data[CONF_DEVICE]})
self._set_confirm_only()
return self.async_show_form(
step_id="usb_confirm",
description_placeholders={
ATTR_MANUFACTURER: MANUFACTURER,
CONF_DEVICE: self.data.get(CONF_DEVICE, ""),
},
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Import a yaml configuration."""
@@ -104,4 +150,4 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN):
def create_enocean_entry(self, user_input):
"""Create an entry for the provided configuration."""
return self.async_create_entry(title="EnOcean", data=user_input)
return self.async_create_entry(title=MANUFACTURER, data=user_input)

View File

@@ -6,6 +6,8 @@ from homeassistant.const import Platform
DOMAIN = "enocean"
MANUFACTURER = "EnOcean"
ERROR_INVALID_DONGLE_PATH = "invalid_dongle_path"
SIGNAL_RECEIVE_MESSAGE = "enocean.receive_message"

View File

@@ -3,10 +3,19 @@
"name": "EnOcean",
"codeowners": [],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/enocean",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["enocean"],
"requirements": ["enocean==0.50"],
"single_config_entry": true
"single_config_entry": true,
"usb": [
{
"description": "*usb 300*",
"manufacturer": "*enocean*",
"pid": "6001",
"vid": "0403"
}
]
}

View File

@@ -25,6 +25,9 @@
"device": "[%key:component::enocean::config::step::detect::data_description::device%]"
},
"description": "Enter the path to your EnOcean USB dongle."
},
"usb_confirm": {
"description": "{manufacturer} USB dongle detected at {device}. Do you want to set up this device?"
}
}
},

View File

@@ -300,16 +300,23 @@ class RuntimeEntryData:
needed_platforms.add(Platform.BINARY_SENSOR)
needed_platforms.add(Platform.SELECT)
needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
info_types_to_platform = INFO_TYPE_TO_PLATFORM
infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
list
)
for info in infos:
infos_by_type[type(info)].append(info)
info_type = type(info)
if platform := info_types_to_platform.get(info_type):
needed_platforms.add(platform)
infos_by_type[info_type].append(info)
else:
_LOGGER.warning(
"Entity type %s is not supported in this version of Home Assistant",
info_type,
)
await self._ensure_platforms_loaded(hass, entry, needed_platforms)
for type_, callbacks in self.entity_info_callbacks.items():
# If all entities for a type are removed, we

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.0.0",
"aioesphomeapi==44.1.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.6.0"
],

View File

@@ -1,7 +1,7 @@
{
"domain": "fritzbox_callmonitor",
"name": "FRITZ!Box Call Monitor",
"codeowners": ["@cdce8p"],
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor",
"integration_type": "device",

View File

@@ -1752,15 +1752,15 @@ class FanSpeedTrait(_Trait):
"""Initialize a trait for a state."""
super().__init__(hass, state, config)
if state.domain == fan.DOMAIN:
speed_count = min(
FAN_SPEED_MAX_SPEED_COUNT,
round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
),
speed_count = round(
100 / (self.state.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 1.0)
)
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
if speed_count <= FAN_SPEED_MAX_SPEED_COUNT:
self._ordered_speed = [
f"{speed}/{speed_count}" for speed in range(1, speed_count + 1)
]
else:
self._ordered_speed = []
@staticmethod
def supported(domain, features, device_class, _):
@@ -1786,7 +1786,11 @@ class FanSpeedTrait(_Trait):
result.update(
{
"reversible": reversible,
"supportsFanSpeedPercent": True,
# supportsFanSpeedPercent is mutually exclusive with
# availableFanSpeeds, where supportsFanSpeedPercent takes
# precedence. Report it only when step speeds are not
# supported so Google renders a percent slider (1-100%).
"supportsFanSpeedPercent": not self._ordered_speed,
}
)
@@ -1832,10 +1836,12 @@ class FanSpeedTrait(_Trait):
if domain == fan.DOMAIN:
percent = attrs.get(fan.ATTR_PERCENTAGE) or 0
response["currentFanSpeedPercent"] = percent
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
if self._ordered_speed:
response["currentFanSpeedSetting"] = percentage_to_ordered_list_item(
self._ordered_speed, percent
)
else:
response["currentFanSpeedPercent"] = percent
return response
@@ -1855,7 +1861,7 @@ class FanSpeedTrait(_Trait):
)
if domain == fan.DOMAIN:
if fan_speed := params.get("fanSpeed"):
if self._ordered_speed and (fan_speed := params.get("fanSpeed")):
fan_speed_percent = ordered_list_item_to_percentage(
self._ordered_speed, fan_speed
)

View File

@@ -181,8 +181,7 @@ class HassIOIngress(HomeAssistantView):
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)
content_length_int = 0
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
# Avoid parsing content_type in simple cases for better performance
if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE):
content_type: str = (maybe_content_type.partition(";"))[0].strip()
@@ -190,17 +189,30 @@ class HassIOIngress(HomeAssistantView):
# default value according to RFC 2616
content_type = "application/octet-stream"
# Empty body responses (304, 204, HEAD, etc.) should not be streamed,
# otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk
# This also avoids setting content_type for empty responses.
if must_be_empty_body(request.method, result.status):
# If upstream contains content-type, preserve it (e.g. for HEAD requests)
# Note: This still is omitting content-length. We can't simply forward
# the upstream length since the proxy might change the body length
# (e.g. due to compression).
if maybe_content_type:
headers[hdrs.CONTENT_TYPE] = content_type
return web.Response(
headers=headers,
status=result.status,
)
# Simple request
if (empty_body := must_be_empty_body(result.method, result.status)) or (
content_length_int = 0
content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED)
if (
content_length is not UNDEFINED
and (content_length_int := int(content_length))
<= MAX_SIMPLE_RESPONSE_SIZE
):
# Return Response
if empty_body:
body = None
else:
body = await result.read()
body = await result.read()
simple_response = web.Response(
headers=headers,
status=result.status,

View File

@@ -7,6 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.4.2"]
}

View File

@@ -0,0 +1,75 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration uses local_push and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: todo
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration has no configuration parameters.
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -11,7 +11,12 @@ from pyHomee import (
)
import voluptuous as vol
from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_USER,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -113,7 +118,22 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery_info.ip_address.version == 6:
return self.async_abort(reason="ipv6_address")
await self.async_set_unique_id(self._name)
# If an already configured homee reports with a second IP, abort.
existing_entry = await self.async_set_unique_id(self._name)
if (
existing_entry
and existing_entry.state == ConfigEntryState.LOADED
and existing_entry.runtime_data.connected
and existing_entry.data[CONF_HOST] != self._host
):
_LOGGER.debug(
"Aborting config flow for discovered homee with IP %s "
"since it is already configured at IP %s",
self._host,
existing_entry.data[CONF_HOST],
)
return self.async_abort(reason="2nd_ip_address")
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
# Cause an auth-error to see if homee is reachable.

View File

@@ -20,6 +20,7 @@ PARALLEL_UPDATES = 0
REMOTE_PROFILES = [
NodeProfile.REMOTE,
NodeProfile.ONE_BUTTON_REMOTE,
NodeProfile.TWO_BUTTON_REMOTE,
NodeProfile.THREE_BUTTON_REMOTE,
NodeProfile.FOUR_BUTTON_REMOTE,

View File

@@ -1,6 +1,7 @@
{
"config": {
"abort": {
"2nd_ip_address": "Your homee is already connected using another IP address",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",

View File

@@ -10,7 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,67 @@
"""Shared entity helpers for Homevolt."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from homevolt import HomevoltAuthenticationError, HomevoltConnectionError, HomevoltError
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltDataUpdateCoordinator
class HomevoltEntity(CoordinatorEntity[HomevoltDataUpdateCoordinator]):
"""Base Homevolt entity."""
_attr_has_entity_name = True
def __init__(
self, coordinator: HomevoltDataUpdateCoordinator, device_identifier: str
) -> None:
"""Initialize the Homevolt entity."""
super().__init__(coordinator)
device_id = coordinator.data.unique_id
device_metadata = coordinator.data.device_metadata.get(device_identifier)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{device_id}_{device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
def homevolt_exception_handler[_HomevoltEntityT: HomevoltEntity, **_P](
func: Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_HomevoltEntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Homevolt calls to handle exceptions."""
async def handler(
self: _HomevoltEntityT, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except HomevoltAuthenticationError as error:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from error
except HomevoltConnectionError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(error)},
) from error
except HomevoltError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(error)},
) from error
return handler

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["homevolt==0.4.4"],
"requirements": ["homevolt==0.5.0"],
"zeroconf": [
{
"name": "homevolt*",

View File

@@ -22,13 +22,11 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity
PARALLEL_UPDATES = 0 # Coordinator-based updates
@@ -309,11 +307,10 @@ async def async_setup_entry(
async_add_entities(entities)
class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEntity):
class HomevoltSensor(HomevoltEntity, SensorEntity):
"""Representation of a Homevolt sensor."""
entity_description: SensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -322,24 +319,12 @@ class HomevoltSensor(CoordinatorEntity[HomevoltDataUpdateCoordinator], SensorEnt
sensor_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
unique_id = coordinator.data.unique_id
self._attr_unique_id = f"{unique_id}_{sensor_key}"
sensor_data = coordinator.data.sensors[sensor_key]
super().__init__(coordinator, sensor_data.device_identifier)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{sensor_key}"
self._sensor_key = sensor_key
device_metadata = coordinator.data.device_metadata.get(
sensor_data.device_identifier
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{unique_id}_{sensor_data.device_identifier}")},
configuration_url=coordinator.client.base_url,
manufacturer=MANUFACTURER,
model=device_metadata.model if device_metadata else None,
name=device_metadata.name if device_metadata else None,
)
@property
def available(self) -> bool:
"""Return if entity is available."""

View File

@@ -160,6 +160,22 @@
"tmin": {
"name": "Minimum temperature"
}
},
"switch": {
"local_mode": {
"name": "Local mode"
}
}
},
"exceptions": {
"auth_failed": {
"message": "[%key:common::config_flow::error::invalid_auth%]"
},
"communication_error": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"unknown_error": {
"message": "[%key:common::config_flow::error::unknown%]"
}
}
}

View File

@@ -0,0 +1,55 @@
"""Support for Homevolt switch entities."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt switch entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltLocalModeSwitch(coordinator)])
class HomevoltLocalModeSwitch(HomevoltEntity, SwitchEntity):
"""Switch entity for Homevolt local mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "local_mode"
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the switch entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_local_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def is_on(self) -> bool:
"""Return true if local mode is enabled."""
return self.coordinator.client.local_mode_enabled
@homevolt_exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable local mode."""
await self.coordinator.client.enable_local_mode()
await self.coordinator.async_request_refresh()
@homevolt_exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable local mode."""
await self.coordinator.client.disable_local_mode()
await self.coordinator.async_request_refresh()

View File

@@ -6,6 +6,7 @@ from enum import Enum
import logging
from typing import Any
from bleak.backends.scanner import AdvertisementData
from HueBLE import ConnectionError, HueBleError, HueBleLight, PairingError
import voluptuous as vol
@@ -26,6 +27,17 @@ from .light import get_available_color_modes
_LOGGER = logging.getLogger(__name__)
SERVICE_UUID = SERVICE_DATA_UUID = "0000fe0f-0000-1000-8000-00805f9b34fb"
def device_filter(advertisement_data: AdvertisementData) -> bool:
"""Return True if the device is supported."""
return (
SERVICE_UUID in advertisement_data.service_uuids
and SERVICE_DATA_UUID in advertisement_data.service_data
)
async def validate_input(hass: HomeAssistant, address: str) -> Error | None:
"""Return error if cannot connect and validate."""
@@ -70,28 +82,66 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {}
self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
unique_id = dr.format_mac(user_input[CONF_MAC])
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
self._discovery_info = self._discovered_devices[user_input[CONF_MAC]]
return await self.async_step_confirm()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in bluetooth.async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not device_filter(discovery.advertisement)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
data_schema = vol.Schema(
{
vol.Required(CONF_MAC): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: bluetooth.BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a flow initialized by the home assistant scanner."""
_LOGGER.debug(
"HA found light %s. Will show in UI but not auto connect",
"HA found light %s. Use user flow to show in UI and connect",
discovery_info.name,
)
unique_id = dr.format_mac(discovery_info.address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
name = f"{discovery_info.name} ({discovery_info.address})"
self.context.update({"title_placeholders": {CONF_NAME: name}})
self._discovery_info = discovery_info
return await self.async_step_confirm()
return self.async_abort(reason="discovery_unsupported")
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
@@ -103,7 +153,10 @@ class HueBleConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
unique_id = dr.format_mac(self._discovery_info.address)
await self.async_set_unique_id(unique_id)
# Don't raise on progress because there may be discovery flows
await self.async_set_unique_id(unique_id, raise_on_progress=False)
# Guard against the user selecting a device which has been configured by
# another flow.
self._abort_if_unique_id_configured()
error = await validate_input(self.hass, unique_id)
if error:

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"not_implemented": "This integration can only be set up via discovery."
"discovery_unsupported": "Discovery flow is not supported by the Hue BLE integration.",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -14,7 +15,16 @@
},
"step": {
"confirm": {
"description": "Do you want to set up {name} ({mac})?. Make sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
"description": "Do you want to set up {name} ({mac})?\nMake sure the light is [made discoverable to voice assistants]({url_pairing_mode}) or has been [factory reset]({url_factory_reset})."
},
"user": {
"data": {
"mac": "[%key:common::config_flow::data::device%]"
},
"data_description": {
"mac": "Select the Hue device you want to set up"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
}

View File

@@ -1,153 +0,0 @@
"""Provides functionality to interact with infrared devices."""
from __future__ import annotations
from abc import abstractmethod
from datetime import timedelta
import logging
from typing import final
from infrared_protocols import Command as InfraredCommand
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
__all__ = [
"DOMAIN",
"InfraredEntity",
"InfraredEntityDescription",
"async_get_emitters",
"async_send_command",
]
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the infrared domain."""
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
)
await component.async_setup(config)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
@callback
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
"""Get all infrared emitters."""
component = hass.data.get(DATA_COMPONENT)
if component is None:
return []
return list(component.entities)
async def async_send_command(
hass: HomeAssistant,
entity_id_or_uuid: str,
command: InfraredCommand,
context: Context | None = None,
) -> None:
"""Send an IR command to the specified infrared entity.
Raises:
HomeAssistantError: If the infrared entity is not found.
"""
component = hass.data.get(DATA_COMPONENT)
if component is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="component_not_loaded",
)
ent_reg = er.async_get(hass)
entity_id = er.async_validate_entity_id(ent_reg, entity_id_or_uuid)
entity = component.get_entity(entity_id)
if entity is None:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={"entity_id": entity_id},
)
if context is not None:
entity.async_set_context(context)
await entity.async_send_command_internal(command)
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
"""Describes infrared entities."""
class InfraredEntity(RestoreEntity):
"""Base class for infrared transmitter entities."""
entity_description: InfraredEntityDescription
_attr_should_poll = False
_attr_state: None = None
__last_command_sent: str | None = None
@property
@final
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_command_sent
@final
async def async_send_command_internal(self, command: InfraredCommand) -> None:
"""Send an IR command and update state.
Should not be overridden, handles setting last sent timestamp.
"""
await self.async_send_command(command)
self.__last_command_sent = dt_util.utcnow().isoformat(timespec="milliseconds")
self.async_write_ha_state()
@final
async def async_internal_added_to_hass(self) -> None:
"""Call when the infrared entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__last_command_sent = state.state
@abstractmethod
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command.
Args:
command: The IR command to send.
Raises:
HomeAssistantError: If transmission fails.
"""

View File

@@ -1,5 +0,0 @@
"""Constants for the Infrared integration."""
from typing import Final
DOMAIN: Final = "infrared"

View File

@@ -1,7 +0,0 @@
{
"entity_component": {
"_": {
"default": "mdi:led-on"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"domain": "infrared",
"name": "Infrared",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==1.0.0"]
}

View File

@@ -1,10 +0,0 @@
{
"exceptions": {
"component_not_loaded": {
"message": "Infrared component not loaded"
},
"entity_not_found": {
"message": "Infrared entity `{entity_id}` not found"
}
}
}

View File

@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import LOGGER
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
PLATFORMS = [Platform.FAN]
PLATFORMS = [Platform.FAN, Platform.SELECT]
async def async_setup_entry(

View File

@@ -27,8 +27,6 @@ class IntelliClimaEntity(CoordinatorEntity[IntelliClimaCoordinator]):
"""Class initializer."""
super().__init__(coordinator=coordinator)
self._attr_unique_id = device.id
# Make this HA "device" use the IntelliClima device name.
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},

View File

@@ -62,6 +62,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
super().__init__(coordinator, device)
self._speed_range = (int(FanSpeed.sleep), int(FanSpeed.high))
self._attr_unique_id = device.id
@property
def is_on(self) -> bool:

View File

@@ -49,7 +49,7 @@ rules:
comment: |
Unclear if discovery is possible.
docs-data-update: done
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -0,0 +1,96 @@
"""Select platform for IntelliClima VMC."""
from pyintelliclima.const import FanMode, FanSpeed
from pyintelliclima.intelliclima_types import IntelliClimaECO
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
from .entity import IntelliClimaECOEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
FAN_MODE_TO_INTELLICLIMA_MODE = {
"forward": FanMode.inward,
"reverse": FanMode.outward,
"alternate": FanMode.alternate,
"sensor": FanMode.sensor,
}
INTELLICLIMA_MODE_TO_FAN_MODE = {v: k for k, v in FAN_MODE_TO_INTELLICLIMA_MODE.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: IntelliClimaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IntelliClima VMC fan mode select."""
coordinator = entry.runtime_data
entities: list[IntelliClimaVMCFanModeSelect] = [
IntelliClimaVMCFanModeSelect(
coordinator=coordinator,
device=ecocomfort2,
)
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
]
async_add_entities(entities)
class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
"""Representation of an IntelliClima VMC fan mode selector."""
_attr_translation_key = "fan_mode"
_attr_options = ["forward", "reverse", "alternate", "sensor"]
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self._attr_unique_id = f"{device.id}_fan_mode"
@property
def current_option(self) -> str | None:
"""Return the current fan mode."""
device_data = self._device_data
if device_data.mode_set == FanMode.off:
return None
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
if (
device_data.speed_set == FanSpeed.auto
and device_data.mode_set == FanMode.sensor
):
return None
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
async def async_select_option(self, option: str) -> None:
"""Set the fan mode."""
device_data = self._device_data
mode = FAN_MODE_TO_INTELLICLIMA_MODE[option]
# Determine speed: keep current speed if available, otherwise default to sleep
if (
device_data.speed_set == FanSpeed.auto
or device_data.mode_set == FanMode.off
):
speed = FanSpeed.sleep
else:
speed = device_data.speed_set
await self.coordinator.api.ecocomfort.set_mode_speed(
self._device_sn, mode, speed
)
await self.coordinator.async_request_refresh()

View File

@@ -22,5 +22,18 @@
"description": "Authenticate against IntelliClima cloud"
}
}
},
"entity": {
"select": {
"fan_mode": {
"name": "Fan direction mode",
"state": {
"alternate": "Alternating",
"forward": "Forward",
"reverse": "Reverse",
"sensor": "Sensor"
}
}
}
}
}

View File

@@ -56,9 +56,7 @@ from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.BUTTON,
Platform.FAN,
Platform.IMAGE,
Platform.INFRARED,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
@@ -133,9 +131,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
# Reload config entry when subentries are added/removed/updated
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Subscribe to labs feature updates for kitchen_sink preview repair
entry.async_on_unload(
async_subscribe_preview_feature(
@@ -152,11 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Reload config entry on update (e.g. subentry added/removed)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
# Notify backup listeners

View File

@@ -8,23 +8,18 @@ from typing import Any
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
OptionsFlowWithReload,
SubentryFlowResult,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.selector import EntitySelector, EntitySelectorConfig
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
from . import DOMAIN
CONF_BOOLEAN = "bool"
CONF_INT = "int"
@@ -49,10 +44,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {
"entity": SubentryFlowHandler,
"infrared_fan": InfraredFanSubentryFlowHandler,
}
return {"entity": SubentryFlowHandler}
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
@@ -73,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="reauth_successful")
class OptionsFlowHandler(OptionsFlow):
class OptionsFlowHandler(OptionsFlowWithReload):
"""Handle options."""
async def async_step_init(
@@ -154,7 +146,7 @@ class SubentryFlowHandler(ConfigSubentryFlow):
"""Reconfigure a sensor."""
if user_input is not None:
title = user_input.pop("name")
return self.async_update_and_abort(
return self.async_update_reload_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=user_input,
@@ -170,35 +162,3 @@ class SubentryFlowHandler(ConfigSubentryFlow):
}
),
)
class InfraredFanSubentryFlowHandler(ConfigSubentryFlow):
"""Handle infrared fan subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to add an infrared fan."""
entities = async_get_emitters(self.hass)
if not entities:
return self.async_abort(reason="no_emitters")
if user_input is not None:
title = user_input.pop("name")
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("name"): str,
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=[entity.entity_id for entity in entities],
)
),
}
),
)

View File

@@ -7,7 +7,6 @@ from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "kitchen_sink"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -1,150 +0,0 @@
"""Demo platform that offers a fake infrared fan entity."""
from __future__ import annotations
from typing import Any
import infrared_protocols
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.components.infrared import async_send_command
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from .const import CONF_INFRARED_ENTITY_ID, DOMAIN
PARALLEL_UPDATES = 0
DUMMY_FAN_ADDRESS = 0x1234
DUMMY_CMD_POWER_ON = 0x01
DUMMY_CMD_POWER_OFF = 0x02
DUMMY_CMD_SPEED_LOW = 0x03
DUMMY_CMD_SPEED_MEDIUM = 0x04
DUMMY_CMD_SPEED_HIGH = 0x05
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared fan platform."""
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "infrared_fan":
continue
async_add_entities(
[
DemoInfraredFan(
subentry_id=subentry_id,
device_name=subentry.title,
infrared_entity_id=subentry.data[CONF_INFRARED_ENTITY_ID],
)
],
config_subentry_id=subentry_id,
)
class DemoInfraredFan(FanEntity):
"""Representation of a demo infrared fan entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_should_poll = False
_attr_assumed_state = True
_attr_speed_count = 3
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
def __init__(
self,
subentry_id: str,
device_name: str,
infrared_entity_id: str,
) -> None:
"""Initialize the demo infrared fan entity."""
self._infrared_entity_id = infrared_entity_id
self._attr_unique_id = subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry_id)},
name=device_name,
)
self._attr_percentage = 0
async def async_added_to_hass(self) -> None:
"""Subscribe to infrared entity state changes."""
await super().async_added_to_hass()
@callback
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
"""Handle infrared entity state changes."""
new_state = event.data["new_state"]
self._attr_available = (
new_state is not None and new_state.state != STATE_UNAVAILABLE
)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
self.hass, [self._infrared_entity_id], _async_ir_state_changed
)
)
# Set initial availability based on current infrared entity state
ir_state = self.hass.states.get(self._infrared_entity_id)
self._attr_available = (
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
)
async def _send_command(self, command_code: int) -> None:
"""Send an IR command using the NEC protocol."""
command = infrared_protocols.NECCommand(
address=DUMMY_FAN_ADDRESS,
command=command_code,
modulation=38000,
)
await async_send_command(
self.hass, self._infrared_entity_id, command, context=self._context
)
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
if percentage is not None:
await self.async_set_percentage(percentage)
return
await self._send_command(DUMMY_CMD_POWER_ON)
self._attr_percentage = 33
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self._send_command(DUMMY_CMD_POWER_OFF)
self._attr_percentage = 0
self.async_write_ha_state()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
if percentage <= 33:
await self._send_command(DUMMY_CMD_SPEED_LOW)
elif percentage <= 66:
await self._send_command(DUMMY_CMD_SPEED_MEDIUM)
else:
await self._send_command(DUMMY_CMD_SPEED_HIGH)
self._attr_percentage = percentage
self.async_write_ha_state()

View File

@@ -1,65 +0,0 @@
"""Demo platform that offers a fake infrared entity."""
from __future__ import annotations
import infrared_protocols
from homeassistant.components import persistent_notification
from homeassistant.components.infrared import InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo infrared platform."""
async_add_entities(
[
DemoInfrared(
unique_id="ir_transmitter",
device_name="IR Blaster",
entity_name="Infrared Transmitter",
),
]
)
class DemoInfrared(InfraredEntity):
"""Representation of a demo infrared entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str,
) -> None:
"""Initialize the demo infrared entity."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
async def async_send_command(self, command: infrared_protocols.Command) -> None:
"""Send an IR command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
persistent_notification.async_create(
self.hass, str(timings), title="Infrared Command"
)

View File

@@ -101,8 +101,6 @@ async def async_setup_entry(
)
for subentry_id, subentry in config_entry.subentries.items():
if subentry.subentry_type != "entity":
continue
async_add_entities(
[
DemoSensor(

View File

@@ -32,24 +32,6 @@
"description": "Reconfigure the sensor"
}
}
},
"infrared_fan": {
"abort": {
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"entry_type": "Infrared fan",
"initiate_flow": {
"user": "Add infrared fan"
},
"step": {
"user": {
"data": {
"infrared_entity_id": "Infrared transmitter",
"name": "[%key:common::config_flow::data::name%]"
},
"description": "Select an infrared transmitter to control the fan."
}
}
}
},
"device": {

View File

@@ -1,4 +1,4 @@
"""The liebherr integration."""
"""The Liebherr integration."""
from __future__ import annotations
@@ -17,7 +17,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> bool:

View File

@@ -1,5 +1,55 @@
{
"entity": {
"select": {
"bio_fresh_plus": {
"default": "mdi:leaf"
},
"bio_fresh_plus_bottom_zone": {
"default": "mdi:leaf"
},
"bio_fresh_plus_middle_zone": {
"default": "mdi:leaf"
},
"bio_fresh_plus_top_zone": {
"default": "mdi:leaf"
},
"hydro_breeze": {
"default": "mdi:weather-windy"
},
"hydro_breeze_bottom_zone": {
"default": "mdi:weather-windy"
},
"hydro_breeze_middle_zone": {
"default": "mdi:weather-windy"
},
"hydro_breeze_top_zone": {
"default": "mdi:weather-windy"
},
"ice_maker": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_bottom_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_middle_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
},
"ice_maker_top_zone": {
"default": "mdi:cube-outline",
"state": {
"off": "mdi:cube-outline-off"
}
}
},
"switch": {
"night_mode": {
"default": "mdi:sleep",

View File

@@ -0,0 +1,216 @@
"""Select platform for Liebherr integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, Any
from pyliebherrhomeapi import (
BioFreshPlusControl,
BioFreshPlusMode,
HydroBreezeControl,
HydroBreezeMode,
IceMakerControl,
IceMakerMode,
ZonePosition,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
PARALLEL_UPDATES = 1
type SelectControl = IceMakerControl | HydroBreezeControl | BioFreshPlusControl
@dataclass(frozen=True, kw_only=True)
class LiebherrSelectEntityDescription(SelectEntityDescription):
"""Describes a Liebherr select entity."""
control_type: type[SelectControl]
mode_enum: type[StrEnum]
current_mode_fn: Callable[[SelectControl], StrEnum | str | None]
options_fn: Callable[[SelectControl], list[str]]
set_fn: Callable[[LiebherrCoordinator, int, StrEnum], Coroutine[Any, Any, None]]
def _ice_maker_options(control: SelectControl) -> list[str]:
"""Return available ice maker options."""
if TYPE_CHECKING:
assert isinstance(control, IceMakerControl)
options = [IceMakerMode.OFF.value, IceMakerMode.ON.value]
if control.has_max_ice:
options.append(IceMakerMode.MAX_ICE.value)
return options
def _hydro_breeze_options(control: SelectControl) -> list[str]:
"""Return available HydroBreeze options."""
return [mode.value for mode in HydroBreezeMode]
def _bio_fresh_plus_options(control: SelectControl) -> list[str]:
"""Return available BioFresh-Plus options."""
if TYPE_CHECKING:
assert isinstance(control, BioFreshPlusControl)
return [
mode.value
for mode in control.supported_modes
if isinstance(mode, BioFreshPlusMode)
]
SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
LiebherrSelectEntityDescription(
key="ice_maker",
translation_key="ice_maker",
control_type=IceMakerControl,
mode_enum=IceMakerMode,
current_mode_fn=lambda c: c.ice_maker_mode, # type: ignore[union-attr]
options_fn=_ice_maker_options,
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_ice_maker(
device_id=coordinator.device_id,
zone_id=zone_id,
mode=mode, # type: ignore[arg-type]
),
),
LiebherrSelectEntityDescription(
key="hydro_breeze",
translation_key="hydro_breeze",
control_type=HydroBreezeControl,
mode_enum=HydroBreezeMode,
current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr]
options_fn=_hydro_breeze_options,
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_hydro_breeze(
device_id=coordinator.device_id,
zone_id=zone_id,
mode=mode, # type: ignore[arg-type]
),
),
LiebherrSelectEntityDescription(
key="bio_fresh_plus",
translation_key="bio_fresh_plus",
control_type=BioFreshPlusControl,
mode_enum=BioFreshPlusMode,
current_mode_fn=lambda c: c.current_mode, # type: ignore[union-attr]
options_fn=_bio_fresh_plus_options,
set_fn=lambda coordinator, zone_id, mode: coordinator.client.set_bio_fresh_plus(
device_id=coordinator.device_id,
zone_id=zone_id,
mode=mode, # type: ignore[arg-type]
),
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr select entities."""
entities: list[LiebherrSelectEntity] = []
for coordinator in entry.runtime_data.values():
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
for control in coordinator.data.controls:
for description in SELECT_TYPES:
if isinstance(control, description.control_type):
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
entities.append(
LiebherrSelectEntity(
coordinator=coordinator,
description=description,
zone_id=control.zone_id,
has_multiple_zones=has_multiple_zones,
)
)
async_add_entities(entities)
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
"""Representation of a Liebherr select entity."""
entity_description: LiebherrSelectEntityDescription
def __init__(
self,
coordinator: LiebherrCoordinator,
description: LiebherrSelectEntityDescription,
zone_id: int,
has_multiple_zones: bool,
) -> None:
"""Initialize the select entity."""
super().__init__(coordinator)
self.entity_description = description
self._zone_id = zone_id
self._attr_unique_id = f"{coordinator.device_id}_{description.key}_{zone_id}"
# Set options from the control
control = self._select_control
if control is not None:
self._attr_options = description.options_fn(control)
# Add zone suffix only for multi-zone devices
if has_multiple_zones:
temp_controls = coordinator.data.get_temperature_controls()
if (
(tc := temp_controls.get(zone_id))
and isinstance(tc.zone_position, ZonePosition)
and (zone_key := ZONE_POSITION_MAP.get(tc.zone_position))
):
self._attr_translation_key = f"{description.translation_key}_{zone_key}"
@property
def _select_control(self) -> SelectControl | None:
"""Get the select control for this entity."""
for control in self.coordinator.data.controls:
if (
isinstance(control, self.entity_description.control_type)
and control.zone_id == self._zone_id
):
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
return control
return None
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
control = self._select_control
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
mode = self.entity_description.current_mode_fn(control)
if isinstance(mode, StrEnum):
return mode.value
return None
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._select_control is not None
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
mode = self.entity_description.mode_enum(option)
await self._async_send_command(
self.entity_description.set_fn(self.coordinator, self._zone_id, mode),
)

View File

@@ -47,6 +47,112 @@
"name": "Top zone setpoint"
}
},
"select": {
"bio_fresh_plus": {
"name": "BioFresh-Plus",
"state": {
"minus_two_minus_two": "-2°C | -2°C",
"minus_two_zero": "-2°C | 0°C",
"zero_minus_two": "0°C | -2°C",
"zero_zero": "0°C | 0°C"
}
},
"bio_fresh_plus_bottom_zone": {
"name": "Bottom zone BioFresh-Plus",
"state": {
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
}
},
"bio_fresh_plus_middle_zone": {
"name": "Middle zone BioFresh-Plus",
"state": {
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
}
},
"bio_fresh_plus_top_zone": {
"name": "Top zone BioFresh-Plus",
"state": {
"minus_two_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_minus_two%]",
"minus_two_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::minus_two_zero%]",
"zero_minus_two": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_minus_two%]",
"zero_zero": "[%key:component::liebherr::entity::select::bio_fresh_plus::state::zero_zero%]"
}
},
"hydro_breeze": {
"name": "HydroBreeze",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"off": "[%key:common::state::off%]"
}
},
"hydro_breeze_bottom_zone": {
"name": "Bottom zone HydroBreeze",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"off": "[%key:common::state::off%]"
}
},
"hydro_breeze_middle_zone": {
"name": "Middle zone HydroBreeze",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"off": "[%key:common::state::off%]"
}
},
"hydro_breeze_top_zone": {
"name": "Top zone HydroBreeze",
"state": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"off": "[%key:common::state::off%]"
}
},
"ice_maker": {
"name": "IceMaker",
"state": {
"max_ice": "MaxIce",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"ice_maker_bottom_zone": {
"name": "Bottom zone IceMaker",
"state": {
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"ice_maker_middle_zone": {
"name": "Middle zone IceMaker",
"state": {
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"ice_maker_top_zone": {
"name": "Top zone IceMaker",
"state": {
"max_ice": "[%key:component::liebherr::entity::select::ice_maker::state::max_ice%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
}
},
"sensor": {
"bottom_zone": {
"name": "Bottom zone"

View File

@@ -109,14 +109,18 @@ class LunatoneLight(
return self._device is not None and self._device.is_on
@property
def brightness(self) -> int:
def brightness(self) -> int | None:
"""Return the brightness of this light between 0..255."""
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
return (
value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
if self._device.brightness is not None
else None
)
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._device is not None and self._device.is_dimmable:
if self._device is not None and self._device.brightness is not None:
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
@@ -149,7 +153,8 @@ class LunatoneLight(
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
if brightness_supported(self.supported_color_modes):
self._last_brightness = self.brightness
if self.brightness:
self._last_brightness = self.brightness
await self._device.fade_to_brightness(0)
else:
await self._device.switch_off()

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.6.3"]
"requirements": ["lunatone-rest-api-client==0.7.0"]
}

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2026.02.04"],
"requirements": ["yt-dlp[default]==2026.02.21"],
"single_config_entry": true
}

View File

@@ -2,10 +2,12 @@
from __future__ import annotations
import asyncio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN as DOMAIN
from .const import DOMAIN as DOMAIN, SUBENTRY_TYPE_BUS, SUBENTRY_TYPE_SUBWAY
from .coordinator import MTAConfigEntry, MTADataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -13,16 +15,36 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
"""Set up MTA from a config entry."""
coordinator = MTADataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
coordinators: dict[str, MTADataUpdateCoordinator] = {}
entry.runtime_data = coordinator
for subentry_id, subentry in entry.subentries.items():
if subentry.subentry_type not in (SUBENTRY_TYPE_SUBWAY, SUBENTRY_TYPE_BUS):
continue
coordinators[subentry_id] = MTADataUpdateCoordinator(hass, entry, subentry)
# Refresh all coordinators in parallel
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators.values()
)
)
entry.runtime_data = coordinators
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> None:
"""Handle config entry update (e.g., subentry changes)."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: MTAConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -2,22 +2,43 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from pymta import LINE_TO_FEED, MTAFeedError, SubwayFeed
from pymta import LINE_TO_FEED, BusFeed, MTAFeedError, SubwayFeed
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers import aiohttp_client
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import CONF_API_KEY
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
from .const import (
CONF_LINE,
CONF_ROUTE,
CONF_STOP_ID,
CONF_STOP_NAME,
DOMAIN,
SUBENTRY_TYPE_BUS,
SUBENTRY_TYPE_SUBWAY,
)
_LOGGER = logging.getLogger(__name__)
@@ -28,17 +49,79 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {
SUBENTRY_TYPE_SUBWAY: SubwaySubentryFlowHandler,
SUBENTRY_TYPE_BUS: BusSubentryFlowHandler,
}
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:
api_key = user_input.get(CONF_API_KEY)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if api_key:
# Test the API key by trying to fetch bus data
session = async_get_clientsession(self.hass)
bus_feed = BusFeed(api_key=api_key, session=session)
try:
# Try to get stops for a known route to validate the key
await bus_feed.get_stops(route_id="M15")
except MTAFeedError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error validating API key")
errors["base"] = "unknown"
if not errors:
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: api_key or None},
)
return self.async_create_entry(
title="MTA",
data={CONF_API_KEY: api_key or None},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Optional(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
)
async def async_step_reauth(
self, _entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth when user wants to add or update API key."""
return await self.async_step_user()
class SubwaySubentryFlowHandler(ConfigSubentryFlow):
"""Handle subway stop subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the line selection step."""
if user_input is not None:
self.data[CONF_LINE] = user_input[CONF_LINE]
return await self.async_step_stop()
@@ -58,13 +141,12 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
),
}
),
errors=errors,
)
async def async_step_stop(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the stop step."""
) -> SubentryFlowResult:
"""Handle the stop selection step."""
errors: dict[str, str] = {}
if user_input is not None:
@@ -74,25 +156,30 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
self.data[CONF_STOP_NAME] = stop_name
unique_id = f"{self.data[CONF_LINE]}_{stop_id}"
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
# Test connection to real-time GTFS-RT feed (different from static GTFS used by get_stops)
# Check for duplicate subentries across all entries
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
# Test connection to real-time GTFS-RT feed
try:
await self._async_test_connection()
except MTAFeedError:
errors["base"] = "cannot_connect"
else:
title = f"{self.data[CONF_LINE]} Line - {stop_name}"
title = f"{self.data[CONF_LINE]} - {stop_name}"
return self.async_create_entry(
title=title,
data=self.data,
unique_id=unique_id,
)
try:
self.stops = await self._async_get_stops(self.data[CONF_LINE])
except MTAFeedError:
_LOGGER.exception("Error fetching stops for line %s", self.data[CONF_LINE])
_LOGGER.debug("Error fetching stops for line %s", self.data[CONF_LINE])
return self.async_abort(reason="cannot_connect")
if not self.stops:
@@ -123,7 +210,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
async def _async_get_stops(self, line: str) -> dict[str, str]:
"""Get stops for a line from the library."""
feed_id = SubwayFeed.get_feed_id_for_route(line)
session = aiohttp_client.async_get_clientsession(self.hass)
session = async_get_clientsession(self.hass)
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
stops_list = await subway_feed.get_stops(route_id=line)
@@ -141,7 +228,7 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
async def _async_test_connection(self) -> None:
"""Test connection to MTA feed."""
feed_id = SubwayFeed.get_feed_id_for_route(self.data[CONF_LINE])
session = aiohttp_client.async_get_clientsession(self.hass)
session = async_get_clientsession(self.hass)
subway_feed = SubwayFeed(feed_id=feed_id, session=session)
await subway_feed.get_arrivals(
@@ -149,3 +236,133 @@ class MTAConfigFlow(ConfigFlow, domain=DOMAIN):
stop_id=self.data[CONF_STOP_ID],
max_arrivals=1,
)
class BusSubentryFlowHandler(ConfigSubentryFlow):
"""Handle bus stop subentry flow."""
def __init__(self) -> None:
"""Initialize the subentry flow."""
self.data: dict[str, Any] = {}
self.stops: dict[str, str] = {}
def _get_api_key(self) -> str:
"""Get API key from parent entry."""
return self._get_entry().data.get(CONF_API_KEY) or ""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the route input step."""
errors: dict[str, str] = {}
if user_input is not None:
route = user_input[CONF_ROUTE].upper().strip()
self.data[CONF_ROUTE] = route
# Validate route by fetching stops
try:
self.stops = await self._async_get_stops(route)
if not self.stops:
errors["base"] = "invalid_route"
else:
return await self.async_step_stop()
except MTAFeedError:
_LOGGER.debug("Error fetching stops for route %s", route)
errors["base"] = "invalid_route"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ROUTE): TextSelector(),
}
),
errors=errors,
)
async def async_step_stop(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the stop selection step."""
errors: dict[str, str] = {}
if user_input is not None:
stop_id = user_input[CONF_STOP_ID]
self.data[CONF_STOP_ID] = stop_id
stop_name = self.stops.get(stop_id, stop_id)
self.data[CONF_STOP_NAME] = stop_name
unique_id = f"bus_{self.data[CONF_ROUTE]}_{stop_id}"
# Check for duplicate subentries across all entries
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
# Test connection to real-time feed
try:
await self._async_test_connection()
except MTAFeedError:
errors["base"] = "cannot_connect"
else:
title = f"{self.data[CONF_ROUTE]} - {stop_name}"
return self.async_create_entry(
title=title,
data=self.data,
unique_id=unique_id,
)
stop_options = [
SelectOptionDict(value=stop_id, label=stop_name)
for stop_id, stop_name in sorted(self.stops.items(), key=lambda x: x[1])
]
return self.async_show_form(
step_id="stop",
data_schema=vol.Schema(
{
vol.Required(CONF_STOP_ID): SelectSelector(
SelectSelectorConfig(
options=stop_options,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
description_placeholders={"route": self.data[CONF_ROUTE]},
)
async def _async_get_stops(self, route: str) -> dict[str, str]:
"""Get stops for a bus route from the library."""
session = async_get_clientsession(self.hass)
api_key = self._get_api_key()
bus_feed = BusFeed(api_key=api_key, session=session)
stops_list = await bus_feed.get_stops(route_id=route)
stops = {}
for stop in stops_list:
stop_id = stop["stop_id"]
stop_name = stop["stop_name"]
# Add direction if available (e.g., "to South Ferry")
if direction := stop.get("direction_name"):
stops[stop_id] = f"{stop_name} (to {direction})"
else:
stops[stop_id] = stop_name
return stops
async def _async_test_connection(self) -> None:
"""Test connection to MTA bus feed."""
session = async_get_clientsession(self.hass)
api_key = self._get_api_key()
bus_feed = BusFeed(api_key=api_key, session=session)
await bus_feed.get_arrivals(
route_id=self.data[CONF_ROUTE],
stop_id=self.data[CONF_STOP_ID],
max_arrivals=1,
)

View File

@@ -7,5 +7,9 @@ DOMAIN = "mta"
CONF_LINE = "line"
CONF_STOP_ID = "stop_id"
CONF_STOP_NAME = "stop_name"
CONF_ROUTE = "route"
SUBENTRY_TYPE_SUBWAY = "subway"
SUBENTRY_TYPE_BUS = "bus"
UPDATE_INTERVAL = timedelta(seconds=30)

View File

@@ -6,22 +6,30 @@ from dataclasses import dataclass
from datetime import datetime
import logging
from pymta import MTAFeedError, SubwayFeed
from pymta import BusFeed, MTAFeedError, SubwayFeed
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, UPDATE_INTERVAL
from .const import (
CONF_LINE,
CONF_ROUTE,
CONF_STOP_ID,
DOMAIN,
SUBENTRY_TYPE_BUS,
UPDATE_INTERVAL,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class MTAArrival:
"""Represents a single train arrival."""
"""Represents a single transit arrival."""
arrival_time: datetime
minutes_until: int
@@ -36,7 +44,7 @@ class MTAData:
arrivals: list[MTAArrival]
type MTAConfigEntry = ConfigEntry[MTADataUpdateCoordinator]
type MTAConfigEntry = ConfigEntry[dict[str, MTADataUpdateCoordinator]]
class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
@@ -44,35 +52,48 @@ class MTADataUpdateCoordinator(DataUpdateCoordinator[MTAData]):
config_entry: MTAConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: MTAConfigEntry) -> None:
def __init__(
self,
hass: HomeAssistant,
config_entry: MTAConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize."""
self.line = config_entry.data[CONF_LINE]
self.stop_id = config_entry.data[CONF_STOP_ID]
self.subentry = subentry
self.stop_id = subentry.data[CONF_STOP_ID]
self.feed_id = SubwayFeed.get_feed_id_for_route(self.line)
session = async_get_clientsession(hass)
self.subway_feed = SubwayFeed(feed_id=self.feed_id, session=session)
if subentry.subentry_type == SUBENTRY_TYPE_BUS:
api_key = config_entry.data.get(CONF_API_KEY) or ""
self.feed: BusFeed | SubwayFeed = BusFeed(api_key=api_key, session=session)
self.route_id = subentry.data[CONF_ROUTE]
else:
# Subway feed
line = subentry.data[CONF_LINE]
feed_id = SubwayFeed.get_feed_id_for_route(line)
self.feed = SubwayFeed(feed_id=feed_id, session=session)
self.route_id = line
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
name=f"{DOMAIN}_{subentry.subentry_id}",
update_interval=UPDATE_INTERVAL,
)
async def _async_update_data(self) -> MTAData:
"""Fetch data from MTA."""
_LOGGER.debug(
"Fetching data for line=%s, stop=%s, feed=%s",
self.line,
"Fetching data for route=%s, stop=%s",
self.route_id,
self.stop_id,
self.feed_id,
)
try:
library_arrivals = await self.subway_feed.get_arrivals(
route_id=self.line,
library_arrivals = await self.feed.get_arrivals(
route_id=self.route_id,
stop_id=self.stop_id,
max_arrivals=3,
)

View File

@@ -38,9 +38,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication required.
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -11,12 +11,13 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_LINE, CONF_STOP_ID, CONF_STOP_NAME, DOMAIN
from .const import CONF_LINE, CONF_ROUTE, CONF_STOP_NAME, DOMAIN, SUBENTRY_TYPE_BUS
from .coordinator import MTAArrival, MTAConfigEntry, MTADataUpdateCoordinator
PARALLEL_UPDATES = 0
@@ -97,16 +98,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MTA sensor based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
MTASensor(coordinator, entry, description)
for description in SENSOR_DESCRIPTIONS
)
for subentry_id, coordinator in entry.runtime_data.items():
subentry = entry.subentries[subentry_id]
async_add_entities(
(
MTASensor(coordinator, subentry, description)
for description in SENSOR_DESCRIPTIONS
),
config_subentry_id=subentry_id,
)
class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
"""Sensor for MTA train arrivals."""
"""Sensor for MTA transit arrivals."""
_attr_has_entity_name = True
entity_description: MTASensorEntityDescription
@@ -114,24 +118,32 @@ class MTASensor(CoordinatorEntity[MTADataUpdateCoordinator], SensorEntity):
def __init__(
self,
coordinator: MTADataUpdateCoordinator,
entry: MTAConfigEntry,
subentry: ConfigSubentry,
description: MTASensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
line = entry.data[CONF_LINE]
stop_id = entry.data[CONF_STOP_ID]
stop_name = entry.data.get(CONF_STOP_NAME, stop_id)
self._attr_unique_id = f"{entry.unique_id}-{description.key}"
is_bus = subentry.subentry_type == SUBENTRY_TYPE_BUS
if is_bus:
route = subentry.data[CONF_ROUTE]
model = "Bus"
else:
route = subentry.data[CONF_LINE]
model = "Subway"
stop_name = subentry.data.get(CONF_STOP_NAME, subentry.subentry_id)
unique_id = subentry.unique_id or subentry.subentry_id
self._attr_unique_id = f"{unique_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"{line} Line - {stop_name} ({stop_id})",
identifiers={(DOMAIN, unique_id)},
name=f"{route} - {stop_name}",
manufacturer="MTA",
model="Subway",
model=model,
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -2,32 +2,95 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_stops": "No stops found for this line. The line may not be currently running."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop and direction"
},
"data_description": {
"stop_id": "Select the stop and direction you want to track"
},
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
"title": "Select stop and direction"
},
"user": {
"data": {
"line": "Line"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"line": "The subway line to track"
"api_key": "API key from MTA Bus Time. Required for bus tracking, optional for subway only."
},
"description": "Choose the subway line you want to track.",
"title": "Select subway line"
"description": "Enter your MTA Bus Time API key to enable bus tracking. Leave blank if you only want to track subways."
}
}
},
"config_subentries": {
"bus": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"entry_type": "Bus stop",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_route": "Invalid bus route. Please check the route name and try again."
},
"initiate_flow": {
"user": "Add bus stop"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop"
},
"data_description": {
"stop_id": "Select the stop you want to track"
},
"description": "Choose a stop on the {route} route.",
"title": "Select stop"
},
"user": {
"data": {
"route": "Route"
},
"data_description": {
"route": "The bus route identifier"
},
"description": "Enter the bus route you want to track (for example, M15, B46, Q10).",
"title": "Enter bus route"
}
}
},
"subway": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_stops": "No stops found for this line. The line may not be currently running."
},
"entry_type": "Subway stop",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"initiate_flow": {
"user": "Add subway stop"
},
"step": {
"stop": {
"data": {
"stop_id": "Stop and direction"
},
"data_description": {
"stop_id": "Select the stop and direction you want to track"
},
"description": "Choose a stop on the {line} line. The direction is included with each stop.",
"title": "Select stop and direction"
},
"user": {
"data": {
"line": "Line"
},
"data_description": {
"line": "The subway line to track"
},
"description": "Choose the subway line you want to track.",
"title": "Select subway line"
}
}
}
},

View File

@@ -81,6 +81,9 @@
"service": "mdi:comment-remove"
},
"publish": {
"sections": {
"actions": "mdi:gesture-tap-button"
},
"service": "mdi:send"
}
}

View File

@@ -27,7 +27,14 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import NtfyConfigEntry
from .entity import NtfyBaseEntity
from .services import ATTR_ATTACH_FILE, ATTR_FILENAME, ATTR_SEQUENCE_ID
from .services import (
ACTIONS_MAP,
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_ATTACH_FILE,
ATTR_FILENAME,
ATTR_SEQUENCE_ID,
)
_LOGGER = logging.getLogger(__name__)
@@ -105,6 +112,15 @@ class NtfyNotifyEntity(NtfyBaseEntity, NotifyEntity):
params.setdefault(ATTR_FILENAME, media.path.name)
actions: list[dict[str, Any]] | None = params.get(ATTR_ACTIONS)
if actions:
params["actions"] = [
ACTIONS_MAP[action[ATTR_ACTION]](
**{k: v for k, v in action.items() if k != ATTR_ACTION}
)
for action in actions
]
msg = Message(topic=self.topic, **params)
try:
await self.ntfy.publish(msg, attachment)

View File

@@ -3,6 +3,7 @@
from datetime import timedelta
from typing import Any
from aiontfy import BroadcastAction, CopyAction, HttpAction, ViewAction
import voluptuous as vol
from yarl import URL
@@ -34,6 +35,28 @@ ATTR_ATTACH_FILE = "attach_file"
ATTR_FILENAME = "filename"
GRP_ATTACHMENT = "attachment"
MSG_ATTACHMENT = "Only one attachment source is allowed: URL or local file"
ATTR_ACTIONS = "actions"
ATTR_ACTION = "action"
ATTR_VIEW = "view"
ATTR_BROADCAST = "broadcast"
ATTR_HTTP = "http"
ATTR_LABEL = "label"
ATTR_URL = "url"
ATTR_CLEAR = "clear"
ATTR_INTENT = "intent"
ATTR_EXTRAS = "extras"
ATTR_METHOD = "method"
ATTR_HEADERS = "headers"
ATTR_BODY = "body"
ATTR_VALUE = "value"
ATTR_COPY = "copy"
ACTIONS_MAP = {
ATTR_VIEW: ViewAction,
ATTR_BROADCAST: BroadcastAction,
ATTR_HTTP: HttpAction,
ATTR_COPY: CopyAction,
}
MAX_ACTIONS_ALLOWED = 3 # ntfy only supports up to 3 actions per notification
def validate_filename(params: dict[str, Any]) -> dict[str, Any]:
@@ -45,6 +68,40 @@ def validate_filename(params: dict[str, Any]) -> dict[str, Any]:
return params
ACTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_LABEL): cv.string,
vol.Optional(ATTR_CLEAR, default=False): cv.boolean,
}
)
VIEW_SCHEMA = ACTION_SCHEMA.extend(
{
vol.Required(ATTR_ACTION): vol.Equal("view"),
vol.Required(ATTR_URL): vol.All(vol.Url(), vol.Coerce(URL)),
}
)
BROADCAST_SCHEMA = ACTION_SCHEMA.extend(
{
vol.Required(ATTR_ACTION): vol.Equal("broadcast"),
vol.Optional(ATTR_INTENT): cv.string,
vol.Optional(ATTR_EXTRAS): dict[str, str],
}
)
HTTP_SCHEMA = VIEW_SCHEMA.extend(
{
vol.Required(ATTR_ACTION): vol.Equal("http"),
vol.Optional(ATTR_METHOD): cv.string,
vol.Optional(ATTR_HEADERS): dict[str, str],
vol.Optional(ATTR_BODY): cv.string,
}
)
COPY_SCHEMA = ACTION_SCHEMA.extend(
{
vol.Required(ATTR_ACTION): vol.Equal("copy"),
vol.Required(ATTR_VALUE): cv.string,
}
)
SERVICE_PUBLISH_SCHEMA = vol.All(
cv.make_entity_service_schema(
{
@@ -69,6 +126,14 @@ SERVICE_PUBLISH_SCHEMA = vol.All(
ATTR_ATTACH_FILE, GRP_ATTACHMENT, MSG_ATTACHMENT
): MediaSelector({"accept": ["*/*"]}),
vol.Optional(ATTR_FILENAME): cv.string,
vol.Optional(ATTR_ACTIONS): vol.All(
cv.ensure_list,
vol.Length(
max=MAX_ACTIONS_ALLOWED,
msg="Too many actions defined. A maximum of 3 is supported",
),
[vol.Any(VIEW_SCHEMA, BROADCAST_SCHEMA, HTTP_SCHEMA, COPY_SCHEMA)],
),
}
),
validate_filename,

View File

@@ -99,6 +99,65 @@ publish:
type: url
autocomplete: url
example: https://example.org/logo.png
actions:
selector:
object:
label_field: "label"
description_field: "url"
multiple: true
translation_key: actions
fields:
action:
required: true
selector:
select:
options:
- value: view
label: Open website/app
- value: http
label: Send HTTP request
- value: broadcast
label: Send Android broadcast
- value: copy
label: Copy to clipboard
translation_key: action_type
mode: dropdown
label:
selector:
text:
required: true
clear:
selector:
boolean:
url:
selector:
text:
type: url
method:
selector:
select:
options:
- GET
- POST
- PUT
- DELETE
custom_value: true
headers:
selector:
object:
body:
selector:
text:
multiline: true
intent:
selector:
text:
extras:
selector:
object:
value:
selector:
text:
sequence_id:
required: false
selector:

View File

@@ -318,6 +318,50 @@
}
},
"selector": {
"actions": {
"fields": {
"action": {
"description": "Select the type of action to add to the notification",
"name": "Action type"
},
"body": {
"description": "The body of the HTTP request for `http` actions.",
"name": "HTTP body"
},
"clear": {
"description": "Clear notification after action button is tapped",
"name": "Clear notification"
},
"extras": {
"description": "Extras to include in the intent as key-value pairs for 'broadcast' actions",
"name": "Intent extras"
},
"headers": {
"description": "Additional HTTP headers as key-value pairs for 'http' actions",
"name": "HTTP headers"
},
"intent": {
"description": "Android intent to send when the 'broadcast' action is triggered",
"name": "Intent"
},
"label": {
"description": "Label of the action button",
"name": "Label"
},
"method": {
"description": "HTTP method to use for the 'http' action",
"name": "HTTP method"
},
"url": {
"description": "URL to open for the 'view' action or to request for the 'http' action",
"name": "URL"
},
"value": {
"description": "Value to copy to clipboard when the 'copy' action is triggered",
"name": "Value"
}
}
},
"priority": {
"options": {
"1": "Minimum",
@@ -350,8 +394,12 @@
"name": "Delete notification"
},
"publish": {
"description": "Publishes a notification message to a ntfy topic",
"description": "Publishes a notification message to a ntfy topic.",
"fields": {
"actions": {
"description": "Up to three actions (`view`, `broadcast`, `http`, or `copy`) can be added as buttons below the notification. Actions are executed when the corresponding button is tapped or clicked.",
"name": "Action buttons"
},
"attach": {
"description": "Attach images or other files by URL.",
"name": "Attachment URL"

View File

@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import ConfigType
@@ -137,3 +138,26 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
hass.config_entries.async_update_entry(entry=entry, version=4)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant,
entry: PortainerConfigEntry,
device: DeviceEntry,
) -> bool:
"""Remove a config entry from a device."""
coordinator = entry.runtime_data
valid_identifiers: set[tuple[str, str]] = set()
# The Portainer integration creates devices for both endpoints and containers. That's why we're doing it double
valid_identifiers.update(
(DOMAIN, f"{entry.entry_id}_{endpoint_id}") for endpoint_id in coordinator.data
)
valid_identifiers.update(
(DOMAIN, f"{entry.entry_id}_{container_name}")
for endpoint in coordinator.data.values()
for container_name in endpoint.containers
)
return not device.identifiers.intersection(valid_identifiers)

View File

@@ -30,11 +30,8 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow:
status: todo
comment: |
No reauthentication flow is defined. It will be done in a next iteration.
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
@@ -47,25 +44,27 @@ rules:
status: exempt
comment: |
No discovery is implemented, since it's software based.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
repair-issues:
status: exempt
comment: |
No repair issues are implemented, currently.
stale-devices: done
# Platinum
async-dependency: todo
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -1,6 +1,6 @@
{
"domain": "powerfox",
"name": "Powerfox",
"name": "Powerfox Cloud",
"codeowners": ["@klaasnicolaas"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/powerfox",

View File

@@ -37,7 +37,10 @@ from .const import (
)
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
PLATFORMS = [Platform.BINARY_SENSOR]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
]
CONFIG_SCHEMA = vol.Schema(

View File

@@ -0,0 +1,339 @@
"""Button platform for Proxmox VE."""
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from proxmoxer import AuthenticationError
from proxmoxer.core import ResourceException
import requests
from requests.exceptions import ConnectTimeout, SSLError
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
@dataclass(frozen=True, kw_only=True)
class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox node button description."""
press_action: Callable[[ProxmoxCoordinator, str], None]
@dataclass(frozen=True, kw_only=True)
class ProxmoxVMButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox VM button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
@dataclass(frozen=True, kw_only=True)
class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription):
"""Class to hold Proxmox container button description."""
press_action: Callable[[ProxmoxCoordinator, str, int], None]
NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = (
ProxmoxNodeButtonNodeEntityDescription(
key="reboot",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).status.post(command="reboot"),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
ProxmoxNodeButtonNodeEntityDescription(
key="shutdown",
translation_key="shutdown",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).status.post(command="shutdown"),
entity_category=EntityCategory.CONFIG,
),
ProxmoxNodeButtonNodeEntityDescription(
key="start_all",
translation_key="start_all",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).startall.post(),
entity_category=EntityCategory.CONFIG,
),
ProxmoxNodeButtonNodeEntityDescription(
key="stop_all",
translation_key="stop_all",
press_action=lambda coordinator, node: coordinator.proxmox.nodes(
node
).stopall.post(),
entity_category=EntityCategory.CONFIG,
),
)
VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
ProxmoxVMButtonEntityDescription(
key="start",
translation_key="start",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.start.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="stop",
translation_key="stop",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.stop.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="restart",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.restart.post()
),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
ProxmoxVMButtonEntityDescription(
key="hibernate",
translation_key="hibernate",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.hibernate.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="reset",
translation_key="reset",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.reset.post()
),
entity_category=EntityCategory.CONFIG,
),
)
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (
ProxmoxContainerButtonEntityDescription(
key="start",
translation_key="start",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.start.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxContainerButtonEntityDescription(
key="stop",
translation_key="stop",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.stop.post()
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxContainerButtonEntityDescription(
key="restart",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).lxc(vmid).status.restart.post()
),
entity_category=EntityCategory.CONFIG,
device_class=ButtonDeviceClass.RESTART,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ProxmoxVE buttons."""
coordinator = entry.runtime_data
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
"""Add new node buttons."""
async_add_entities(
ProxmoxNodeButtonEntity(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_BUTTONS
)
def _async_add_new_vms(
vms: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new VM buttons."""
async_add_entities(
ProxmoxVMButtonEntity(coordinator, entity_description, vm, node_data)
for (node_data, vm) in vms
for entity_description in VM_BUTTONS
)
def _async_add_new_containers(
containers: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new container buttons."""
async_add_entities(
ProxmoxContainerButtonEntity(
coordinator, entity_description, container, node_data
)
for (node_data, container) in containers
for entity_description in CONTAINER_BUTTONS
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
coordinator.new_vms_callbacks.append(_async_add_new_vms)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
_async_add_new_nodes(
[
node_data
for node_data in coordinator.data.values()
if node_data.node["node"] in coordinator.known_nodes
]
)
_async_add_new_vms(
[
(node_data, vm_data)
for node_data in coordinator.data.values()
for vmid, vm_data in node_data.vms.items()
if (node_data.node["node"], vmid) in coordinator.known_vms
]
)
_async_add_new_containers(
[
(node_data, container_data)
for node_data in coordinator.data.values()
for vmid, container_data in node_data.containers.items()
if (node_data.node["node"], vmid) in coordinator.known_containers
]
)
class ProxmoxBaseButton(ButtonEntity):
"""Common base for Proxmox buttons. Basically to ensure the async_press logic isn't duplicated."""
entity_description: ButtonEntityDescription
coordinator: ProxmoxCoordinator
@abstractmethod
async def _async_press_call(self) -> None:
"""Abstract method used per Proxmox button class."""
async def async_press(self) -> None:
"""Trigger the Proxmox button press service."""
try:
await self._async_press_call()
except AuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except SSLError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except ConnectTimeout as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error_no_details",
) from err
class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton):
"""Represents a Proxmox Node button entity."""
entity_description: ProxmoxNodeButtonNodeEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxNodeButtonNodeEntityDescription,
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox Node button entity."""
self.entity_description = entity_description
super().__init__(coordinator, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the node button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_data.node["node"],
)
class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton):
"""Represents a Proxmox VM button entity."""
entity_description: ProxmoxVMButtonEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxVMButtonEntityDescription,
vm_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox VM button entity."""
self.entity_description = entity_description
super().__init__(coordinator, vm_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the VM button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
self.vm_data["vmid"],
)
class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton):
"""Represents a Proxmox Container button entity."""
entity_description: ProxmoxContainerButtonEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxContainerButtonEntityDescription,
container_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox Container button entity."""
self.entity_description = entity_description
super().__init__(coordinator, container_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
async def _async_press_call(self) -> None:
"""Execute the container button action via executor."""
await self.hass.async_add_executor_job(
self.entity_description.press_action,
self.coordinator,
self._node_name,
self.container_data["vmid"],
)

View File

@@ -0,0 +1,18 @@
{
"entity": {
"button": {
"hibernate": {
"default": "mdi:power-sleep"
},
"reset": {
"default": "mdi:restart"
},
"start": {
"default": "mdi:play"
},
"stop": {
"default": "mdi:stop"
}
}
}
}

View File

@@ -54,15 +54,47 @@
"status": {
"name": "Status"
}
},
"button": {
"hibernate": {
"name": "Hibernate"
},
"reset": {
"name": "Reset"
},
"shutdown": {
"name": "Shutdown"
},
"start": {
"name": "Start"
},
"start_all": {
"name": "Start all"
},
"stop": {
"name": "Stop"
},
"stop_all": {
"name": "Stop all"
}
}
},
"exceptions": {
"api_error_no_details": {
"message": "An error occurred while communicating with the Proxmox VE instance."
},
"cannot_connect": {
"message": "An error occurred while trying to connect to the Proxmox VE instance: {error}"
},
"cannot_connect_no_details": {
"message": "Could not connect to the Proxmox VE instance."
},
"invalid_auth": {
"message": "An error occurred while trying to authenticate: {error}"
},
"invalid_auth_no_details": {
"message": "Authentication failed for the Proxmox VE instance."
},
"no_nodes_found": {
"message": "No active nodes were found on the Proxmox VE server."
},
@@ -71,6 +103,9 @@
},
"timeout_connect": {
"message": "A timeout occurred while trying to connect to the Proxmox VE instance: {error}"
},
"timeout_connect_no_details": {
"message": "A timeout occurred while trying to connect to the Proxmox VE instance."
}
},
"issues": {

View File

@@ -9,6 +9,7 @@ from satel_integra.satel_integra import AlarmState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .client import SatelClient
@@ -16,6 +17,8 @@ from .const import ZONES
_LOGGER = logging.getLogger(__name__)
PARTITION_UPDATE_DEBOUNCE_DELAY = 0.15
@dataclass
class SatelIntegraData:
@@ -106,9 +109,21 @@ class SatelIntegraPartitionsCoordinator(
self.data = {}
self._debouncer = Debouncer(
hass=self.hass,
logger=_LOGGER,
cooldown=PARTITION_UPDATE_DEBOUNCE_DELAY,
immediate=False,
function=callback(
lambda: self.async_set_updated_data(
self.client.controller.partition_states
)
),
)
@callback
def partitions_update_callback(self) -> None:
"""Update partition objects as per notification from the alarm."""
_LOGGER.debug("Sending request to update panel state")
self.async_set_updated_data(self.client.controller.partition_states)
self._debouncer.async_schedule_call()

View File

@@ -160,7 +160,10 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
if self._device.connected:
if self.is_volume_muted or self._current_group.muted:
return MediaPlayerState.IDLE
return STREAM_STATUS.get(self._current_group.stream_status)
try:
return STREAM_STATUS.get(self._current_group.stream_status)
except KeyError:
pass
return MediaPlayerState.OFF
@property
@@ -275,10 +278,15 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
@property
def metadata(self) -> Mapping[str, Any]:
"""Get metadata from the current stream."""
if metadata := self.coordinator.server.stream(
self._current_group.stream
).metadata:
return metadata
try:
if metadata := self.coordinator.server.stream(
self._current_group.stream
).metadata:
return metadata
except (
KeyError
): # the stream function raises KeyError if the stream does not exist
pass
# Fallback to an empty dict
return {}
@@ -333,11 +341,15 @@ class SnapcastClientDevice(SnapcastCoordinatorEntity, MediaPlayerEntity):
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
# Position is part of properties object, not metadata object
if properties := self.coordinator.server.stream(
self._current_group.stream
).properties:
if (value := properties.get("position")) is not None:
return int(value)
try:
# Position is part of properties object, not metadata object
if properties := self.coordinator.server.stream(
self._current_group.stream
).properties:
if (value := properties.get("position")) is not None:
return int(value)
except (
KeyError
): # the stream function raises KeyError if the stream does not exist
pass
return None

View File

@@ -7,7 +7,7 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["hass_splunk"],
"quality_scale": "legacy",
"quality_scale": "bronze",
"requirements": ["hass-splunk==0.1.4"],
"single_config_entry": true
}

View File

@@ -18,18 +18,9 @@ rules:
status: exempt
comment: |
Integration does not provide custom actions.
docs-high-level-description:
status: todo
comment: |
Verify integration docs at https://www.home-assistant.io/integrations/splunk/ include a high-level description of Splunk with a link to https://www.splunk.com/ and explain the integration's purpose for users unfamiliar with Splunk.
docs-installation-instructions:
status: todo
comment: |
Verify integration docs include clear prerequisites and step-by-step setup instructions including how to configure Splunk HTTP Event Collector and obtain the required token.
docs-removal-instructions:
status: todo
comment: |
Verify integration docs include instructions on how to remove the integration and clarify what happens to data already in Splunk.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |

View File

@@ -2,7 +2,7 @@
from abc import abstractmethod
import asyncio
from collections.abc import Callable, Sequence
from collections.abc import Awaitable, Callable, Sequence
import io
import logging
import os
@@ -430,48 +430,35 @@ class TelegramNotificationService:
params[ATTR_PARSER] = None
return params
async def _send_msgs(
async def _send_msg_formatted(
self,
func_send: Callable,
func_send: Callable[..., Awaitable[Message]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
**kwargs_msg: Any,
) -> dict[str, JsonValueType]:
"""Sends a message to each of the targets.
If there is only 1 targtet, an error is raised if the send fails.
For multiple targets, errors are logged and the caller is responsible for checking which target is successful/failed based on the return value.
"""Sends a message and formats the response.
:return: dict with chat_id keys and message_id values for successful sends
"""
chat_ids = [kwargs_msg.pop(ATTR_CHAT_ID)]
msg_ids: dict[str, JsonValueType] = {}
for chat_id in chat_ids:
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID)
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
for file_type in _FILE_TYPES:
if file_type in kwargs_msg and isinstance(
kwargs_msg[file_type], io.BytesIO
):
kwargs_msg[file_type].seek(0)
response: Message = await self._send_msg(
func_send,
message_tag,
chat_id,
*args_msg,
context=context,
**kwargs_msg,
)
response: Message = await self._send_msg(
func_send,
message_tag,
chat_id,
*args_msg,
context=context,
**kwargs_msg,
)
if response:
msg_ids[str(chat_id)] = response.id
return msg_ids
return {str(chat_id): response.id}
async def _send_msg(
self,
func_send: Callable,
func_send: Callable[..., Awaitable[Any]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
@@ -518,7 +505,7 @@ class TelegramNotificationService:
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_message,
params[ATTR_MESSAGE_TAG],
text,
@@ -759,7 +746,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_PHOTO:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_photo,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -775,7 +762,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_STICKER:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -789,7 +776,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_VIDEO:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_video,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -805,7 +792,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_DOCUMENT:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_document,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -821,7 +808,7 @@ class TelegramNotificationService:
)
if file_type == SERVICE_SEND_VOICE:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_voice,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -836,7 +823,7 @@ class TelegramNotificationService:
)
# SERVICE_SEND_ANIMATION
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_animation,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -861,7 +848,7 @@ class TelegramNotificationService:
stickerid = kwargs.get(ATTR_STICKER_ID)
if stickerid:
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -886,7 +873,7 @@ class TelegramNotificationService:
latitude = float(latitude)
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_location,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
@@ -911,7 +898,7 @@ class TelegramNotificationService:
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
return await self._send_msgs(
return await self._send_msg_formatted(
self.bot.send_poll,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.35.0"]
"requirements": ["pyTibber==0.36.0"]
}

View File

@@ -13,7 +13,7 @@ OAUTH2_AUTHORIZE = (
OAUTH2_TOKEN = (
"https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/token/"
)
API_URL = "https://api.weheat.nl"
API_URL = "https://api.weheat.nl/third_party"
OAUTH2_SCOPES = ["openid", "offline_access"]

View File

@@ -39,6 +39,33 @@
"electricity_used": {
"default": "mdi:flash"
},
"electricity_used_cooling": {
"default": "mdi:flash"
},
"electricity_used_defrost": {
"default": "mdi:flash"
},
"electricity_used_dhw": {
"default": "mdi:flash"
},
"electricity_used_heating": {
"default": "mdi:flash"
},
"energy_output": {
"default": "mdi:flash"
},
"energy_output_cooling": {
"default": "mdi:snowflake"
},
"energy_output_defrost": {
"default": "mdi:snowflake"
},
"energy_output_dhw": {
"default": "mdi:heat-wave"
},
"energy_output_heating": {
"default": "mdi:heat-wave"
},
"heat_pump_state": {
"default": "mdi:state-machine"
},

View File

@@ -221,6 +221,73 @@ ENERGY_SENSORS = [
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_output,
),
WeHeatSensorEntityDescription(
translation_key="electricity_used_heating",
key="electricity_used_heating",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_in_heating,
),
WeHeatSensorEntityDescription(
translation_key="electricity_used_cooling",
key="electricity_used_cooling",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_in_cooling,
),
WeHeatSensorEntityDescription(
translation_key="electricity_used_defrost",
key="electricity_used_defrost",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_in_defrost,
),
WeHeatSensorEntityDescription(
translation_key="energy_output_heating",
key="energy_output_heating",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_out_heating,
),
WeHeatSensorEntityDescription(
translation_key="energy_output_cooling",
key="energy_output_cooling",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
value_fn=lambda status: status.energy_out_cooling,
),
WeHeatSensorEntityDescription(
translation_key="energy_output_defrost",
key="energy_output_defrost",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
value_fn=lambda status: status.energy_out_defrost,
),
]
DHW_ENERGY_SENSORS = [
WeHeatSensorEntityDescription(
translation_key="electricity_used_dhw",
key="electricity_used_dhw",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_in_dhw,
),
WeHeatSensorEntityDescription(
translation_key="energy_output_dhw",
key="energy_output_dhw",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda status: status.energy_out_dhw,
),
]
@@ -253,6 +320,16 @@ async def async_setup_entry(
if entity_description.value_fn(weheatdata.data_coordinator.data)
is not None
)
entities.extend(
WeheatHeatPumpSensor(
weheatdata.heat_pump_info,
weheatdata.energy_coordinator,
entity_description,
)
for entity_description in DHW_ENERGY_SENSORS
if entity_description.value_fn(weheatdata.energy_coordinator.data)
is not None
)
entities.extend(
WeheatHeatPumpSensor(
weheatdata.heat_pump_info,

View File

@@ -84,9 +84,33 @@
"electricity_used": {
"name": "Electricity used"
},
"electricity_used_cooling": {
"name": "Electricity used cooling"
},
"electricity_used_defrost": {
"name": "Electricity used defrost"
},
"electricity_used_dhw": {
"name": "Electricity used DHW"
},
"electricity_used_heating": {
"name": "Electricity used heating"
},
"energy_output": {
"name": "Total energy output"
},
"energy_output_cooling": {
"name": "Energy output cooling"
},
"energy_output_defrost": {
"name": "Energy output defrost"
},
"energy_output_dhw": {
"name": "Energy output DHW"
},
"energy_output_heating": {
"name": "Energy output heating"
},
"heat_pump_state": {
"state": {
"cooling": "Cooling",

View File

@@ -12,6 +12,8 @@
"documentation": "https://www.home-assistant.io/integrations/xbox",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["python-xbox==0.1.3"],
"ssdp": [
{

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: has only entity actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: has only entity actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: The integration has no configuration options
docs-installation-parameters:
status: exempt
comment: The integration has no installation parameters
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: Discovery is only used to start/suggest the OAuth flow; there is no connection info to update
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: exempt
comment: nothing to reconfigure
repair-issues:
status: exempt
comment: has no repairs
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -17,6 +17,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "airobot",
"hostname": "airobot-thermostat-*",
},
{
"domain": "airos",
"registered_devices": True,
},
{
"domain": "airthings",
"hostname": "airthings-view",

View File

@@ -29,7 +29,6 @@ class EntityPlatforms(StrEnum):
HUMIDIFIER = "humidifier"
IMAGE = "image"
IMAGE_PROCESSING = "image_processing"
INFRARED = "infrared"
LAWN_MOWER = "lawn_mower"
LIGHT = "light"
LOCK = "lock"

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