Compare commits

...

117 Commits

Author SHA1 Message Date
Erik Montnemery 6ad8ad5715 Call state change listeners immediately instead of deferring them to the event loop (#173974) 2026-06-16 16:50:32 +02:00
Åke Strandberg a45867b896 Use username as config entry title in aqvify (#174008) 2026-06-16 14:30:25 +02:00
Franck Nijhof 000e075a8e Add missing subentry flow translations in scrape (#174006) 2026-06-16 14:26:12 +02:00
Franck Nijhof 0899d016b9 Add missing flow form field translations in ecobee (#174002) 2026-06-16 14:25:40 +02:00
Franck Nijhof 3375f2ed76 Add missing flow form field translation in otp (#173994) 2026-06-16 14:25:29 +02:00
Franck Nijhof 3f5778e71b Add missing flow form field translations in tractive (#174005) 2026-06-16 14:10:47 +02:00
Franck Nijhof 86c39694d3 Add missing flow form field translation in iskra (#174004) 2026-06-16 13:48:58 +02:00
Franck Nijhof a53a6644c0 Fix flow form field translations in modem_callerid (#173999) 2026-06-16 13:47:01 +02:00
Franck Nijhof 18fdfacf45 Fix flow form field translation key in sia (#173998) 2026-06-16 13:46:27 +02:00
Franck Nijhof bd9bd29f2c Add missing flow form field translation in airvisual (#174000) 2026-06-16 13:46:19 +02:00
Franck Nijhof 334c6614cc Fix flow form field translations in local_calendar (#173997) 2026-06-16 13:43:22 +02:00
Franck Nijhof aa772f6ecd Add missing flow form field translation in honeywell (#173996) 2026-06-16 13:41:18 +02:00
Franck Nijhof 87169921ae Fix flow form field translations in hlk_sw16 (#173993) 2026-06-16 13:39:40 +02:00
Franck Nijhof 16338b8b6b Fix flow form field translation keys in here_travel_time (#173992) 2026-06-16 13:38:50 +02:00
Åke Strandberg 519da3c9c9 Add aqvify devices dynamically (#173534) 2026-06-16 13:37:42 +02:00
Åke Strandberg 6f34718c1f Bump pyaqvify to 0.0.11 (#173989) 2026-06-16 13:37:02 +02:00
Tim Laing e4287bb43c Bump PyiCloud to 2.6.5 (#173928) 2026-06-16 13:05:50 +02:00
Mike O'Driscoll d724ebac2a casper_glow: add bluetooth reachability diagnostics (#173921) 2026-06-16 13:02:46 +02:00
Raphael Hehl dc480051db Use console name in UniFi Network discovery title (#173931) 2026-06-16 12:57:51 +02:00
Franck Nijhof 63b6ced9c4 Bump evolutionhttp to 0.0.19 (#173911) 2026-06-16 12:46:21 +02:00
Franck Nijhof 34e9b3ff1e Bump lunatone-rest-api-client to 0.9.2 (#173918) 2026-06-16 12:45:19 +02:00
Robert Resch 210746525e Fix missing full sha as hidden field in requirements check aw (#173900) 2026-06-16 11:05:08 +02:00
Robert Resch 0134e99366 Token views should behave the same (#173500) 2026-06-16 10:46:18 +02:00
Oscar Calvo 06de89d6a3 Fix CCM15 temperature unit to follow the device's C/F setting (#173788) 2026-06-16 10:41:58 +02:00
Paul Bottein 4c267617f8 Publish numeric sensor device classes as generated sensor.json (#173919) 2026-06-16 11:41:27 +03:00
Franck Nijhof a82f1a7a1d Bump pyfireservicerota to 0.0.49 (#173935) 2026-06-16 10:36:34 +02:00
Franck Nijhof d234f65dd9 Bump heatmiserV3 to 2.0.6 (#173913)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-16 10:36:03 +02:00
John Pettitt 30148980e1 Add API_GEN_4 support to Subaru integration (#173956) 2026-06-16 10:32:02 +02:00
Franck Nijhof 1fa9a3353c Bump eufylife-ble-client to 0.1.10 (#173934) 2026-06-16 10:30:52 +02:00
Raphael Hehl 2dbbd70085 Use console name in UniFi Protect discovery title (#173966) 2026-06-16 10:25:50 +02:00
Franck Nijhof 73903b0bfc Bump pysesame2 to 1.0.2 (#173904) 2026-06-16 10:20:23 +02:00
Franck Nijhof b09f54ce3b Bump foobot_async to 1.0.1 (#173905) 2026-06-16 10:19:38 +02:00
Franck Nijhof 6d9e41da07 Bump pencompy to 0.0.4 (#173906) 2026-06-16 10:17:46 +02:00
Franck Nijhof f5600a602f Bump webexpythonsdk to 2.0.6 (#173916) 2026-06-16 09:50:37 +02:00
Franck Nijhof d83cd941a7 Bump hole to 0.9.2 (#173936) 2026-06-16 09:37:58 +02:00
Franck Nijhof 2120cad533 Bump pdunehd to 1.3.3 (#173907)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-06-16 09:34:44 +02:00
dependabot[bot] fb4e72af77 Bump home-assistant/builder from 2026.03.2 to 2026.06.0 (#173963)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 09:22:22 +02:00
Paul Bottein badd4130b6 Bump yoto-api to 4.3.0 (#173910) 2026-06-16 09:18:10 +02:00
Franck Nijhof 7a4ca4dcfd Bump roombapy to 1.9.1 (#173922) 2026-06-16 09:14:40 +02:00
Franck Nijhof 9b47a0d440 Bump atenpdu to 0.3.6 (#173932) 2026-06-16 09:10:16 +02:00
Franck Nijhof 4b99e81a8a Bump influxdb to 5.3.2 (#173891) 2026-06-16 08:54:20 +02:00
Franck Nijhof 62e5238f43 Bump tellcore-py to 1.1.3 (#173894) 2026-06-16 08:53:50 +02:00
Franck Nijhof 149c884a89 Bump DoorBirdPy to 3.0.12 (#173923) 2026-06-16 08:52:50 +02:00
Franck Nijhof 71ca453c42 Bump pyhomematic to 0.1.78 (#173925) 2026-06-16 08:52:27 +02:00
Franck Nijhof aad6080307 Bump omnilogic to 0.4.9 (#173938) 2026-06-16 08:51:40 +02:00
Franck Nijhof 2db2e0b0cf Bump aioairq to 0.4.8 (#173940) 2026-06-16 08:50:50 +02:00
Franck Nijhof 3fc36ab6f9 Bump messagebird to 1.2.1 (#173942) 2026-06-16 08:49:56 +02:00
Denis Shulyaka 0fad24393c Fix docs-data-update IQS for Anthropic (#173947) 2026-06-16 08:21:09 +02:00
Raphael Hehl a992a58367 Use console name in UniFi Access discovery title (#173962) 2026-06-16 08:20:29 +02:00
jasonjhofmann f0cefe2f2e Add network MAC connection to Rain Bird controller (#173672)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:15:33 +02:00
jasonjhofmann 40264992a2 Add network MAC connection to AnthemAV main zone device (#173682)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:12:52 +02:00
jasonjhofmann c29aebd60e Add network MAC connection to PlayStation 4 devices (#173681)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:11:25 +02:00
jasonjhofmann 36b74d6f05 Add network MAC connection to iAlarm device (#173676)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:02:49 +02:00
jasonjhofmann 2c626fa8f0 Add network MAC connection to Rabbit Air devices (#173684)
Co-authored-by: jasonjhofmann <16144532+jasonjhofmann@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:01:22 +02:00
jasonjhofmann cab0d015f6 Add network MAC connection to Aprilaire devices (#173675)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:00:13 +02:00
Erik Montnemery c544f95979 Prime condition durations from history (#173426) 2026-06-16 07:53:41 +02:00
renovate[bot] 2189d0ae74 Update infrared-protocols to 6.0.1 (#173958) 2026-06-16 07:53:22 +02:00
Raphael Hehl 9e96a06aff Bump unifi-discovery to 1.5.0 (#173927) 2026-06-16 00:06:01 +02:00
Franck Nijhof d16e0e9867 Bump greeclimate to 2.1.4 (#173924) 2026-06-15 22:53:59 +02:00
Franck Nijhof 2209996919 Bump pyipma to 3.0.10 (#173943) 2026-06-15 22:09:00 +02:00
Franck Nijhof d88767155b Bump pykrakenapi to 0.1.9 (#173933) 2026-06-15 21:47:44 +02:00
Franck Nijhof 334d02077f Bump pypck to 0.9.13 (#173914) 2026-06-15 21:46:57 +02:00
Franck Nijhof 2b7e9289d2 Bump librouteros to 3.2.1 (#173937) 2026-06-15 21:41:42 +02:00
Åke Strandberg c57358dd23 Bump pyaqvify to 0.0.10 (#173926) 2026-06-15 20:55:05 +02:00
alexborro e151478d78 Add reauthentication flow to Aquacell (#173110) 2026-06-15 20:37:03 +02:00
Mick Vleeshouwer e41b1f5279 Use device.supports_command in Overkiz (#173280) 2026-06-15 20:18:05 +02:00
LG-ThinQ-Integration 4203aed863 Add off operation_mode to SYSTEM_BOILER in LG ThinQ (#173070)
Co-authored-by: YunseonPark-LGE <yunseon.park@lge.com>
2026-06-15 20:15:08 +02:00
Nikolai Rahimi e7e116843f Raise an error when a Mitsubishi Comfort command is rejected (#173363) 2026-06-15 20:03:18 +02:00
Petro31 d781baca7e Add xy color to template lights (#173296) 2026-06-15 20:02:09 +02:00
Marcello 855962dcd0 Add reauthentication flow to Fluss+ (#173341)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:01:20 +02:00
Tomasz Dylewski cf914f559f Add battery sensor support for PAJ GPS devices (#173123) 2026-06-15 19:47:03 +02:00
Crocmagnon a420a6c990 data grand lyon: pick velo'v stop (#173407) 2026-06-15 19:46:17 +02:00
Peter Grauvogel 5f470d49a5 Add cheapest duration actions to Green Planet Energy integration (#162577)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-06-15 19:38:17 +02:00
Jason Bonta bd2638f144 Add long_press support for HomeWorks QSX in lutron_caseta (#172634)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 19:36:27 +02:00
Franck Nijhof b397d6fd05 Bump pygtfs to 0.1.11 (#173917) 2026-06-15 18:56:53 +02:00
Kevin Stillhammer eb2ee43e6f Remove eifinger as Broadlink codeowner (#173908) 2026-06-15 18:28:36 +02:00
Franck Nijhof 9d16e59899 Bump pykaleidescape to 1.1.6 (#173912) 2026-06-15 18:27:34 +02:00
Erik Montnemery 2434341e04 Queue nested firing of events (#173519) 2026-06-15 17:27:16 +02:00
Franck Nijhof 047edc035d Skip literal_eval for template results that cannot be a Python literal (#173664)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-15 17:24:29 +02:00
epenet 8b5f27e016 Optimize module parsing in pylint imports checker (#173077) 2026-06-15 17:12:02 +02:00
Markus Adrario 5200a8131f Homee: QS examples done (#173543) 2026-06-15 17:11:02 +02:00
Manu 2dc1870ecd Add notify entities to SMTP integration (#173557) 2026-06-15 16:32:24 +02:00
Josef Zweck d8f125dfe9 Add connectivity binary sensor to opendisplay (#172539) 2026-06-15 16:29:41 +02:00
Paulus Schoutsen 311cd56c93 Expose on-disk file path when resolving TTS media source (#172884)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 09:25:56 -05:00
Erwin Douna 4b17e3abcb MELCloud Home add diagnostics platform (#173583) 2026-06-15 16:24:03 +02:00
Martin Hjelmare f2839bbf7a Add util.dt.naive_now (#173443) 2026-06-15 16:22:42 +02:00
Mick Vleeshouwer 0229545184 Fix Atlantic DHW Production V2 CE FLAT C2 water heater controls in Overkiz (#172823) 2026-06-15 16:21:28 +02:00
bkobus-bbx e8ce995560 Add DHCP discovery support to BleBox integration (#173498) 2026-06-15 16:20:55 +02:00
johanzander 46ffb3bd95 Fix Growatt total_output_power 1000x too low with V1 API (#172474)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 16:19:44 +02:00
Åke Strandberg 27677a07a6 Aqvify reaches silver tier on quality scale (#173618) 2026-06-15 16:12:44 +02:00
Tim Laing f619ccca4b Feature/icloud media browser (#162001)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
2026-06-15 16:11:58 +02:00
Christian Lackas 09a72ac505 homematicip_cloud: harden post-reconnect state recovery using 2.9.0 diagnostics (#169526) 2026-06-15 16:08:40 +02:00
BrettLynch123 27573c5231 Fix daikin setup_error on transient DaikinException during startup (#173660) 2026-06-15 16:05:06 +02:00
Franck Nijhof d5f23fffa8 Bump pyvizio to 0.1.64 (#173859)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
2026-06-15 16:04:18 +02:00
Paul van Schayck 3b70ac987d Migrate unifi_direct from DeviceScanner to ScannerEntity and add ConfigFlow (#171991) 2026-06-15 16:02:12 +02:00
epenet e00b8f154e Add pylint checker for direct calls to component.async_unload_entry (#173870) 2026-06-15 15:49:30 +02:00
Joost Lekkerkerker abc751fd1c Update agents to avoid useless comments (#173523)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-06-15 14:48:02 +01:00
Franck Nijhof 6b5c7ec864 Bump pyHik to 0.4.3 (#173889) 2026-06-15 15:47:48 +02:00
Jakub Brzezowski d63bb48040 Add integration for Greencell HabuDen EVSE (#145302) 2026-06-15 15:43:53 +02:00
Åke Strandberg b71b155ffb Automatic delete of stale devices for aqvify (#173496) 2026-06-15 15:39:33 +02:00
Franck Nijhof 0f59a6070f Include spoken language in Google Generative AI STT prompt (#173631) 2026-06-15 15:39:15 +02:00
Robert Resch bb34887983 Fix aw check requirements (#173893) 2026-06-15 15:35:49 +02:00
Paul Bottein 6a06873527 Add binary sensors to Yoto (#173612) 2026-06-15 15:33:53 +02:00
epenet c012acc685 Move incorrect test from manual to demo integration (#173888) 2026-06-15 15:25:12 +02:00
Robert Resch 735ef5fc14 Add head SHA tracking to requirements check workflow (#173874) 2026-06-15 15:24:02 +02:00
epenet 405b9db101 Refactor energyid tests to avoid direct call to async_unload_entry (#173885) 2026-06-15 15:21:10 +02:00
Matt Brown 57aede0e27 Include host address in broadlink setup timeout/error messages (#173661) 2026-06-15 15:18:51 +02:00
Franck Nijhof c9d7d842ff Avoid walking script variable ChainMap twice when tracing (#173665) 2026-06-15 15:18:28 +02:00
Manu 9e8af2d098 Remove MS Teams integration (#173643) 2026-06-15 15:14:18 +02:00
EnjoyingM 90dc3717b0 Bump wolflink to 0.0.52 (#173884) 2026-06-15 15:10:05 +02:00
Paul Bottein a7c70d4d26 Add sensors to Yoto (#173292) 2026-06-15 15:05:39 +02:00
Joakim Plate 1dc5f1b768 Abort gardena discovery before product detection if address is known (#173799) 2026-06-15 14:54:54 +02:00
wollew e9f4bea715 Refactor polling rain sensor to use coordinator in Velux integration (#168991)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
2026-06-15 14:54:22 +02:00
Neffez f2aa8aa73d Support WiZ lights with unadvertised dual head ratio (#172854) 2026-06-15 14:36:20 +02:00
Rasmus Graham 6f0831ebbb Improve Verisure session handling and reauth flow (#171317) 2026-06-15 14:35:17 +02:00
Franck Nijhof 579fbd2ae8 Add pylint checker for unnecessary format_mac in connection tuples (#173729) 2026-06-15 14:33:23 +02:00
AlCalzone e056c7d78c Deprecate openSenseMap air quality entity (#173862)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:33:05 +02:00
371 changed files with 14384 additions and 1565 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not comment on code style, formatting or linting issues.
- Flag comments that over-explain straightforward code, narrate the obvious, or read like AI commentary (multi-sentence justifications for a single line).
- A Pull Request with a dependency version bump should only contain changes required for the version bump. If the PR includes other changes, request that they are removed from the PR.
# GitHub Copilot & Claude Code Instructions
@@ -50,4 +51,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
+2 -2
View File
@@ -193,7 +193,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Build base image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -264,7 +264,7 @@ jobs:
fi
- name: Build machine image
uses: home-assistant/builder/actions/build-image@62a1597b84b3461abad9816d9cd92862a2b542c3 # 2026.03.2
uses: home-assistant/builder/actions/build-image@4de35182ce1e329181bffcbcc84d33db5e2c7e10 # 2026.06.0
with:
arch: ${{ matrix.arch }}
build-args: |
@@ -50,19 +50,24 @@ jobs:
check-latest: true
- name: Install script dependencies
run: pip install -r script/check_requirements/requirements.txt
- name: Collect PR diff
- name: Collect PR diff and head SHA
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
run: |
mkdir -p deterministic
gh pr diff "${PR_NUMBER}" > deterministic/pr.diff
HEAD_SHA=$(gh pr view "${PR_NUMBER}" --json headRefOid --jq '.headRefOid')
echo "head_sha=${HEAD_SHA}" >> "${GITHUB_OUTPUT}"
- name: Run deterministic checks
env:
PR_NUMBER: ${{ inputs.pull_request_number || github.event.pull_request.number }}
HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
run: |
python -m script.check_requirements \
--pr-number "${PR_NUMBER}" \
--head-sha "${HEAD_SHA}" \
--diff deterministic/pr.diff \
--output deterministic/results.json
- name: Upload deterministic-results artifact
+77 -3
View File
@@ -1,4 +1,4 @@
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"e4fcdd04986da27ef3059faa0cea3d64bb879fe12085ebfdec0041bbc31ec181","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7b142e96e0f8b454cdcc9c0c25070cf9a52c44d83a6b1fbc3ad6725b6567337c","body_hash":"3894ded07d5934ac5f29d160ffb1f9115cf72b6da8a7e453a4d4f69e8641a48e","compiler_version":"v0.79.6","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"v0.79.6","version":"v0.79.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
# ___ _ _
# / _ \ | | (_)
@@ -345,6 +345,7 @@ jobs:
needs:
- activation
- extract_pr_number
- gate
if: needs.activation.outputs.daily_effective_workflow_exceeded != 'true'
runs-on: ubuntu-latest
permissions:
@@ -994,6 +995,7 @@ jobs:
- agent
- detection
- extract_pr_number
- gate
- safe_outputs
if: >
always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
@@ -1428,8 +1430,8 @@ jobs:
}
extract_pr_number:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
@@ -1460,6 +1462,78 @@ jobs:
PR=$(jq -r '.pr_number' /tmp/deterministic/results.json)
echo "pr_number=${PR}" >> "${GITHUB_OUTPUT}"
gate:
needs: activation
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
# zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
run: |
# Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
# GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
GH_HOST="${GITHUB_SERVER_URL#https://}"
GH_HOST="${GH_HOST#http://}"
echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
- name: Decide whether requirements changed since the last comment
id: gate
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pre_activation:
runs-on: ubuntu-slim
outputs:
+62 -1
View File
@@ -22,9 +22,70 @@ safe-outputs:
needs:
- extract_pr_number
jobs:
extract_pr_number:
gate:
# Skip the (token-spending) agent when no tracked requirement file changed
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: read
outputs:
skip: ${{ steps.gate.outputs.skip }}
steps:
- name: Download deterministic-results artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: check-requirements-deterministic
path: /tmp/gate
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Decide whether requirements changed since the last comment
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR=$(jq -r '.pr_number' /tmp/gate/results.json)
HEAD=$(jq -r '.head_sha // empty' /tmp/gate/results.json)
if [ -z "${HEAD}" ]; then
echo "Artifact has no head_sha; running the agent."
exit 0
fi
# Recover the commit recorded in the most recent requirements-check
# comment from the "Checked at commit" link
PRIOR=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
--jq '.[] | select(.body | contains("<!-- requirements-check -->")) | .body' \
| grep -oiE '/commit/[0-9a-f]{40}' \
| grep -oiE '[0-9a-f]{40}' | tail -1 || true)
if [ -z "${PRIOR}" ]; then
echo "No previous comment with a recorded commit; running the agent."
exit 0
fi
if [ "${PRIOR}" = "${HEAD}" ]; then
echo "Head ${HEAD} unchanged since the last comment; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
# List files changed between the recorded commit and the current head.
# Tracked patterns mirror script/check_requirements/diff.py TRACKED_PATTERNS.
CHANGED=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${PRIOR}...${HEAD}" \
--jq '.files[].filename' 2>/dev/null) || {
echo "Could not compare ${PRIOR}...${HEAD}; running the agent."
exit 0
}
TRACKED=$(printf '%s\n' "${CHANGED}" \
| grep -Ex 'requirements.*\.txt|homeassistant/package_constraints\.txt' || true)
if [ -z "${TRACKED}" ]; then
echo "No tracked requirement files changed since ${PRIOR}; skipping the agent."
echo "skip=true" >> "${GITHUB_OUTPUT}"
else
echo "Tracked requirement files changed since ${PRIOR}; running the agent:"
printf '%s\n' "${TRACKED}"
fi
extract_pr_number:
needs: gate
if: needs.gate.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
actions: read
outputs:
+1 -1
View File
@@ -102,7 +102,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(conditions|quality_scale|services|triggers)\.yaml|homeassistant/brands/.*\.json|script/hassfest/(?!metadata|mypy_config).+\.py|homeassistant/components/sensor/const\.py|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker
+2 -1
View File
@@ -40,4 +40,5 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
- When reviewing entity actions, do not suggest extra defensive checks for input fields that are already validated by Home Assistant's service/action schemas and entity selection filters. Suggest additional guards only when data bypasses those validators or is transformed into a less-safe form.
- When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")` so contract violations are surfaced instead of silently masked.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why — non-obvious constraints, surprising behavior, or workarounds — never what.
- Keep comments concise. Prefer one short line stating the non-obvious constraint, or no comment at all.
- Do not add comments that just restate the code on the following line(s) (e.g. `# Check if initialized` above `if self.initialized:`). Comments should only explain why (non-obvious constraints, surprising behavior, or workarounds), never what. Never add comments that justify a change by referencing what the code looked like before.
Generated
+5 -3
View File
@@ -262,8 +262,8 @@ CLAUDE.md @home-assistant/core
/tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r
/tests/components/bring/ @miaucl @tr4nt0r
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am @eifinger
/homeassistant/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/tests/components/broadlink/ @danielhiversen @felipediel @L-I-Am
/homeassistant/components/brother/ @bieniu
/tests/components/brother/ @bieniu
/homeassistant/components/brottsplatskartan/ @gjohansson-ST
@@ -695,6 +695,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
/tests/components/greencell/ @BrzezowskiGC
/homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
@@ -1155,7 +1157,6 @@ CLAUDE.md @home-assistant/core
/tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys
@@ -1891,6 +1892,7 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/tests/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifi_discovery/ @RaHehl
/tests/components/unifi_discovery/ @RaHehl
/homeassistant/components/unifiled/ @florisvdk
-1
View File
@@ -11,7 +11,6 @@
"microsoft_face_identify",
"microsoft_face",
"microsoft",
"msteams",
"onedrive",
"onedrive_for_business",
"xbox"
+1 -1
View File
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairq"],
"requirements": ["aioairq==0.4.7"],
"requirements": ["aioairq==0.4.8"],
"zeroconf": [
{
"properties": {
@@ -37,6 +37,9 @@
"title": "Re-authenticate AirVisual"
},
"user": {
"data": {
"type": "Integration type"
},
"description": "Pick what type of AirVisual data you want to monitor.",
"title": "Configure AirVisual"
}
@@ -1,10 +1,6 @@
"""The AirVisual Pro integration."""
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,7 +28,7 @@ class AirVisualProEntity(CoordinatorEntity[AirVisualProCoordinator]):
connections={
(
CONNECTION_NETWORK_MAC,
format_mac(self.coordinator.data["status"]["mac_address"]),
self.coordinator.data["status"]["mac_address"],
)
},
manufacturer="AirVisual",
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -87,9 +87,12 @@ class AnthemAVR(MediaPlayerEntity):
via_device=(DOMAIN, mac_address),
)
else:
# Zone 1 is the physical receiver that owns the network MAC; higher
# zones are via_device children and carry no connection.
self._attr_unique_id = mac_address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mac_address)},
connections={(CONNECTION_NETWORK_MAC, mac_address)},
name=name,
manufacturer=MANUFACTURER,
model=model,
@@ -52,10 +52,7 @@ rules:
status: exempt
comment: |
Service integration, no discovery.
docs-data-update:
status: exempt
comment: |
No data updates.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
@@ -193,6 +193,7 @@ class AprilaireCoordinator(BaseDataUpdateCoordinatorProtocol):
device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
connections={(dr.CONNECTION_NETWORK_MAC, data[Attribute.MAC_ADDRESS])},
name=self.create_device_name(data),
manufacturer="Aprilaire",
)
@@ -1,5 +1,6 @@
"""Config flow for Aquacell integration."""
from collections.abc import Mapping
from datetime import datetime
import logging
from typing import Any
@@ -31,6 +32,12 @@ DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_SCHEMA = vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
)
class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Aquacell."""
@@ -77,3 +84,48 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=DATA_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
session = async_get_clientsession(self.hass)
api = AquacellApi(
session, reauth_entry.data.get(CONF_BRAND, Brand.AQUACELL)
)
try:
refresh_token = await api.authenticate(
reauth_entry.data[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except ApiException, TimeoutError:
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_REFRESH_TOKEN: refresh_token,
CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(),
},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_SCHEMA,
description_placeholders={"email": reauth_entry.data[CONF_EMAIL]},
errors=errors,
)
@@ -14,7 +14,7 @@ from aioaquacell import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
@@ -79,7 +79,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]):
softeners = await self.aquacell_api.get_all_softeners()
except AuthenticationFailed as err:
raise ConfigEntryError from err
raise ConfigEntryAuthFailed from err
except (AquacellApiException, TimeoutError) as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,13 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"description": "The password for {email} is no longer valid. Enter your current softener mobile app password to reconnect.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"brand": "Brand",
@@ -59,7 +59,9 @@ class AqvifyConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(), data_updates=user_input
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title="Aqvify", data=user_input)
return self.async_create_entry(
title=account_data.name or "Aqvify", data=user_input
)
return self.async_show_form(
step_id="user",
+26 -2
View File
@@ -12,6 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -49,6 +50,7 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
self.api_client = AqvifyAPI(
entry.data[CONF_API_KEY], websession=async_get_clientsession(hass)
)
self.previous_devices: set[str] = set()
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -102,10 +104,25 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
},
) from err
current_devices = set(devices.devices.keys())
if stale_devices := self.previous_devices - current_devices:
account_id = self.config_entry.unique_id
device_registry = dr.async_get(self.hass)
for device_id in stale_devices:
device = device_registry.async_get_device(
identifiers={(DOMAIN, f"{account_id}_{device_id}")}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
device_data = {}
for device in devices.devices.values():
for aqvify_device in devices.devices.values():
try:
device_key = str(device.device_key)
device_key = str(aqvify_device.device_key)
device_data[
device_key
] = await self.api_client.async_get_device_latest_data(device_key)
@@ -135,3 +152,10 @@ class AqvifyCoordinator(DataUpdateCoordinator[AqvifyCoordinatorData]):
devices=devices,
device_data=device_data,
)
def async_add_devices(self, added_devices: set[str]) -> tuple[set[str], set[str]]:
"""Return newly discovered device keys and the full current device set."""
current_devices = set(self.data.devices.devices)
new_devices: set[str] = current_devices - added_devices
return (new_devices, current_devices)
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyaqvify"],
"quality_scale": "bronze",
"requirements": ["pyaqvify==0.0.9"]
"quality_scale": "silver",
"requirements": ["pyaqvify==0.0.11"]
}
@@ -29,16 +29,28 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions:
status: exempt
comment: |
The integration does not provide any actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
docs-configuration-parameters:
status: exempt
comment: |
There are no configuration options.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: |
Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: todo
+17 -5
View File
@@ -59,11 +59,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aqvify sensor entities from a config entry."""
async_add_entities(
AqvifySensor(entry.runtime_data, description, device_key)
for description in ENTITIES
for device_key in entry.runtime_data.data.devices.devices
)
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _async_add_new_devices() -> None:
nonlocal added_devices
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
added_devices = current_devices
async_add_entities(
AqvifySensor(coordinator, description, device_key)
for description in ENTITIES
for device_key in new_devices_set
)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
class AqvifySensor(AqvifyBaseEntity, SensorEntity):
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/aten_pe",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["atenpdu==0.3.2"]
"requirements": ["atenpdu==0.3.6"]
}
+43 -23
View File
@@ -17,6 +17,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import get_maybe_authenticated_session
@@ -75,6 +76,21 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"address": f"{host}:{port}"},
)
async def _async_box_from_host_or_abort(
self, api_host: ApiHost
) -> Box | ConfigFlowResult:
"""Try to connect to the device; return product or an abort result."""
try:
return await Box.async_from_host(api_host)
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except Error:
return self.async_abort(reason="cannot_connect")
async def _async_from_host_or_form(
self, api_host: ApiHost, user_input: dict[str, Any], step_id: str
) -> tuple[Box, None] | tuple[None, ConfigFlowResult]:
@@ -101,45 +117,50 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
ex, schema, host, port, UNKNOWN, _LOGGER.error, step_id
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
hass = self.hass
ipaddress = (discovery_info.host, discovery_info.port)
self.device_config["host"] = discovery_info.host
self.device_config["port"] = discovery_info.port
websession = async_get_clientsession(hass)
async def _async_handle_discovery(self, host: str, port: int) -> ConfigFlowResult:
"""Handle discovery by IP and port; probe device then confirm with the user."""
self.device_config["host"] = host
self.device_config["port"] = port
websession = async_get_clientsession(self.hass)
api_host = ApiHost(
*ipaddress, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER
host, port, DEFAULT_SETUP_TIMEOUT, websession, self.hass.loop, _LOGGER
)
try:
product = await Box.async_from_host(api_host)
except UnauthorizedRequest:
return self.async_abort(reason="authorization_required")
except UnsupportedBoxVersion:
return self.async_abort(reason="unsupported_device_version")
except UnsupportedBoxResponse:
return self.async_abort(reason="unsupported_device_response")
result = await self._async_box_from_host_or_abort(api_host)
if not isinstance(result, Box):
return result
product = result
self.device_config["name"] = product.name
# Check if configured but IP changed since
await self.async_set_unique_id(product.unique_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self.context.update(
{
"title_placeholders": {
"name": self.device_config["name"],
"host": self.device_config["host"],
"host": host,
},
"configuration_url": f"http://{discovery_info.host}",
"configuration_url": f"http://{host}",
}
)
return await self.async_step_confirm_discovery()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
return await self._async_handle_discovery(discovery_info.ip, DEFAULT_PORT)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
return await self._async_handle_discovery(
discovery_info.host, discovery_info.port or DEFAULT_PORT
)
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -158,7 +179,6 @@ class BleBoxConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={
"name": self.device_config["name"],
"host": self.device_config["host"],
"port": self.device_config["port"],
},
)
@@ -3,6 +3,45 @@
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"config_flow": true,
"dhcp": [
{ "hostname": "rollergate*" },
{ "hostname": "gatebox*" },
{ "hostname": "doorbox*" },
{ "hostname": "shutterbox*" },
{ "hostname": "switchbox*" },
{ "hostname": "dimmerbox*" },
{ "hostname": "dacbox*" },
{ "hostname": "wlightbox*" },
{ "hostname": "pixelbox*" },
{ "hostname": "saunabox*" },
{ "hostname": "thermobox*" },
{ "hostname": "tempsensor*" },
{ "hostname": "energymeter*" },
{ "hostname": "airsensor*" },
{ "hostname": "humiditysensor*" },
{ "hostname": "rainsensor*" },
{ "hostname": "floodsensor*" },
{ "hostname": "luxsensor*" },
{ "hostname": "inputsensor*" },
{ "hostname": "opensensor*" },
{ "hostname": "windsensor*" },
{ "hostname": "co2sensor*" },
{ "hostname": "simongo*" },
{ "hostname": "sabaj-k-smrt*" },
{ "hostname": "rico*" },
{ "hostname": "smartrollergate*" },
{ "hostname": "darco_ero_32ws_0*" },
{ "hostname": "pergoladc*" },
{ "hostname": "seltsmartscreen*" },
{ "hostname": "seltvenetianblind*" },
{ "hostname": "doorunitbox*" },
{ "hostname": "drutexsmart*" },
{ "hostname": "swingatecontroller*" },
{ "hostname": "windowopener*" },
{ "hostname": "smartawning*" },
{ "hostname": "smartshade*" },
{ "hostname": "smartshutter*" }
],
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",
"iot_class": "local_polling",
@@ -4,6 +4,7 @@
"address_already_configured": "A BleBox device is already configured at {address}.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"authorization_required": "The BleBox device requires authentication.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The device identifier does not match the previously configured device.",
@@ -18,6 +19,10 @@
},
"flow_title": "{name} ({host})",
"step": {
"confirm_discovery": {
"description": "Do you want to add the BleBox device **{name}** at `{host}` to Home Assistant?",
"title": "BleBox device discovered"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
+1 -1
View File
@@ -105,7 +105,7 @@ class BluesoundButton(CoordinatorEntity[BluesoundCoordinator], ButtonEntity):
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -118,7 +118,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if port == DEFAULT_PORT:
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, format_mac(sync_status.mac))},
connections={(CONNECTION_NETWORK_MAC, sync_status.mac)},
name=sync_status.name,
manufacturer=sync_status.brand,
model=sync_status.model_name,
@@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(shc_info.unique_id))},
connections={(dr.CONNECTION_NETWORK_MAC, shc_info.unique_id)},
identifiers={(DOMAIN, shc_info.unique_id)},
manufacturer="Bosch",
name=entry.title,
@@ -123,7 +123,14 @@ class _BrandsBaseView(HomeAssistantView):
)
if not authenticated:
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
async def _serve_from_custom_integration(
+8 -1
View File
@@ -118,7 +118,14 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]:
return False
except (NetworkTimeoutError, OSError) as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connect_failed",
translation_placeholders={
"host": api.host[0],
"error": str(err),
},
) from err
except BroadlinkException as err:
_LOGGER.error(
@@ -1,7 +1,7 @@
{
"domain": "broadlink",
"name": "Broadlink",
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am"],
"config_flow": true,
"dhcp": [
{
@@ -89,6 +89,9 @@
}
},
"exceptions": {
"connect_failed": {
"message": "Failed to connect to the device at {host}: {error}"
},
"frequency_not_supported": {
"message": "Broadlink devices cannot transmit on {frequency} MHz"
},
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/bryant_evolution",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["evolutionhttp==0.0.18"]
"requirements": ["evolutionhttp==0.0.19"]
}
+2 -6
View File
@@ -31,11 +31,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -75,7 +71,7 @@ def get_bsblan_device_info(
"""Build DeviceInfo for the main BSB-LAN controller device."""
return DeviceInfo(
identifiers={(DOMAIN, device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
connections={(CONNECTION_NETWORK_MAC, device.MAC)},
name=device.name,
manufacturer="BSBLAN Inc.",
model=(
+1 -1
View File
@@ -460,7 +460,7 @@ class EventTrigger(Trigger):
listener = TargetCalendarEventListener(
self._hass, target_selection, self._event_type, offset, run_action
)
return listener.async_setup()
return await listener.async_setup()
class EventStartedTrigger(EventTrigger):
+10 -4
View File
@@ -785,7 +785,9 @@ class CameraView(HomeAssistantView):
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
"""Start a GET request."""
if (camera := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -793,11 +795,15 @@ class CameraView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or camera access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
if not camera.is_on:
@@ -3,6 +3,7 @@
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -27,7 +28,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found",
translation_placeholders={"address": address},
translation_placeholders={
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass, address.upper(), BluetoothReachabilityIntent.CONNECTION
),
},
)
glow = CasperGlow(ble_device)
@@ -56,7 +56,7 @@
"message": "An error occurred while communicating with the Casper Glow: {error}"
},
"device_not_found": {
"message": "Could not find Casper Glow device with address {address}"
"message": "Could not find Casper Glow device with address {address}: {reason}"
}
}
}
+7 -1
View File
@@ -49,7 +49,6 @@ async def async_setup_entry(
class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Climate device for CCM15 coordinator."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_target_temperature_step = PRECISION_WHOLE
_attr_hvac_modes = [
@@ -93,6 +92,13 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity):
"""Return device data."""
return self.coordinator.get_ac_data(self._ac_index)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement reported by the device."""
if (data := self.data) is not None and not data.is_celsius:
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> int | None:
"""Return current temperature."""
@@ -3,11 +3,7 @@
from cieloconnectapi.device import CieloDeviceAPI
from cieloconnectapi.model import CieloDevice
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -69,7 +65,7 @@ class CieloDeviceEntity(CieloBaseEntity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=device.name,
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="Cielo",
configuration_url="https://home.cielowigle.com/",
suggested_area=device.name,
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["webexpythonsdk"],
"quality_scale": "legacy",
"requirements": ["webexpythonsdk==2.0.1"]
"requirements": ["webexpythonsdk==2.0.6"]
}
@@ -5,6 +5,7 @@ import logging
from aiohttp import ClientConnectionError
from pydaikin.daikin_base import Appliance
from pydaikin.exceptions import DaikinException
from pydaikin.factory import DaikinFactory
from homeassistant.const import (
@@ -56,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
except ClientConnectionError as err:
_LOGGER.debug("ClientConnectionError to %s", host)
raise ConfigEntryNotReady from err
except DaikinException as err:
# pydaikin has no subclass hierarchy for transient vs permanent errors.
# DaikinException during factory/init almost always means the device is not
# yet ready (e.g. "Empty values." when the unit hasn't finished booting),
# so treat all factory-time DaikinExceptions as transient.
_LOGGER.debug("DaikinException from %s: %s", host, err)
raise ConfigEntryNotReady from err
coordinator = DaikinCoordinator(hass, entry, device)
@@ -5,7 +5,12 @@ import logging
from typing import Any
from aiohttp import ClientError, ClientResponseError
from data_grand_lyon_ha import DataGrandLyonClient, TclStop, find_tcl_stop_by_id
from data_grand_lyon_ha import (
DataGrandLyonClient,
TclStop,
VelovStation,
find_tcl_stop_by_id,
)
import voluptuous as vol
from homeassistant.config_entries import (
@@ -49,12 +54,6 @@ STEP_RECONFIGURE_SCHEMA = vol.Schema(
}
)
STEP_VELOV_STATION_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_STATION_ID): vol.Coerce(int),
}
)
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Data Grand Lyon."""
@@ -302,27 +301,96 @@ def _stop_label(stop: TclStop) -> str:
class VelovStationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for adding a Vélo'v station."""
def __init__(self) -> None:
"""Initialize the flow."""
self._stations: list[VelovStation] = []
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle the user step to add a new Vélo'v station."""
entry = self._get_entry()
"""Pick a station from the list fetched from the API, or enter one manually."""
if not self._stations:
if error := await self._async_load_stations():
return self.async_abort(reason=error)
errors: dict[str, str] = {}
if user_input is not None:
station_id = user_input[CONF_STATION_ID]
unique_id = f"velov_{station_id}"
try:
station_id = int(user_input[CONF_STATION_ID])
except ValueError:
errors[CONF_STATION_ID] = "invalid_station_id"
else:
entry = self._get_entry()
unique_id = f"velov_{station_id}"
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
for subentry in entry.subentries.values():
if subentry.unique_id == unique_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=f"Vélo'v {station_id}",
data={CONF_STATION_ID: station_id},
unique_id=unique_id,
return self.async_create_entry(
title=f"Vélo'v {station_id}",
data={CONF_STATION_ID: station_id},
unique_id=unique_id,
)
options = [
SelectOptionDict(
value=str(station.number), label=_velov_station_label(station)
)
for station in sorted(
self._stations,
key=lambda s: (s.name, s.commune or "", s.number or 0),
)
]
schema = vol.Schema(
{
vol.Required(CONF_STATION_ID): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
sort=False,
custom_value=True,
)
)
}
)
return self.async_show_form(
step_id="user",
data_schema=STEP_VELOV_STATION_DATA_SCHEMA,
data_schema=schema,
errors=errors,
)
async def _async_load_stations(self) -> str | None:
"""Fetch Vélo'v stations from the API, returning an error key on failure."""
entry = self._get_entry()
session = async_get_clientsession(self.hass)
client = DataGrandLyonClient(
session=session,
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
self._stations = await client.get_velov_stations()
except ClientResponseError as err:
if err.status in (401, 403):
return "invalid_auth"
return "cannot_connect"
except ClientError, TimeoutError:
return "cannot_connect"
except Exception:
_LOGGER.exception(
"Unexpected error fetching Data Grand Lyon Vélo'v stations"
)
return "unknown"
return None
def _velov_station_label(station: VelovStation) -> str:
label = station.name
if station.address or station.commune:
label += (
" (" + ", ".join(filter(None, [station.address, station.commune])) + ")"
)
label += f" - {station.number}"
return label
@@ -76,16 +76,25 @@
},
"velov_station": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"entry_type": "Vélo'v station",
"error": {
"invalid_station_id": "Station ID must be a number."
},
"initiate_flow": {
"user": "Add Vélo'v station"
},
"step": {
"user": {
"data": {
"station_id": "Station ID"
"station_id": "Station"
},
"data_description": {
"station_id": "Search by station name, address or city, or enter a station ID directly."
}
}
}
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["doorbirdpy"],
"requirements": ["DoorBirdPy==3.0.11"],
"requirements": ["DoorBirdPy==3.0.12"],
"zeroconf": [
{
"properties": {
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pdunehd"],
"requirements": ["pdunehd==1.3.2"]
"requirements": ["pdunehd==1.3.3"]
}
+3 -1
View File
@@ -30,7 +30,9 @@
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Please enter the API key obtained from ecobee.com."
}
+2 -8
View File
@@ -1,11 +1,7 @@
"""Base entity for the Elgato integration."""
from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -33,6 +29,4 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]):
hw_version=str(coordinator.data.info.hardware_board_type),
)
if (mac := coordinator.config_entry.data.get(CONF_MAC)) is not None:
self._attr_device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(mac))
}
self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)}
@@ -93,7 +93,7 @@ class Enigma2UpdateCoordinator(DataUpdateCoordinator[OpenWebIfStatus]):
if "mac" in iface and iface["mac"] is not None
}
self.device_info[ATTR_CONNECTIONS] = {
(CONNECTION_NETWORK_MAC, format_mac(iface["mac"]))
(CONNECTION_NETWORK_MAC, iface["mac"])
for iface in about["info"]["ifaces"]
if "mac" in iface and iface["mac"] is not None
}
@@ -2,6 +2,12 @@
"domain": "eufylife_ble",
"name": "EufyLife",
"bluetooth": [
{
"local_name": "eufy T9120"
},
{
"local_name": "eufy T9130"
},
{
"local_name": "eufy T9140"
},
@@ -16,6 +22,9 @@
},
{
"local_name": "eufy T9149"
},
{
"local_name": "eufy T9150"
}
],
"codeowners": ["@bdr99"],
@@ -24,5 +33,5 @@
"documentation": "https://www.home-assistant.io/integrations/eufylife_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["eufylife-ble-client==0.1.8"]
"requirements": ["eufylife-ble-client==0.1.10"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.46"]
"requirements": ["pyfireservicerota==0.0.49"]
}
+44 -12
View File
@@ -1,5 +1,6 @@
"""Config flow for Fluss+ integration."""
from collections.abc import Mapping
from typing import Any
from fluss_api import (
@@ -22,6 +23,21 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string})
class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fluss+."""
async def _validate_api_key(self, api_key: str) -> dict[str, str]:
"""Validate the API key and return any errors."""
errors: dict[str, str] = {}
client = FlussApiClient(api_key, session=async_get_clientsession(self.hass))
try:
await client.async_get_devices()
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
return errors
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -31,18 +47,7 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
client = FlussApiClient(
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
)
try:
await client.async_get_devices()
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
errors = await self._validate_api_key(api_key)
if not errors:
return self.async_create_entry(
title="My Fluss+ Devices", data=user_input
@@ -51,3 +56,30 @@ class FlussConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication when the API key is no longer valid."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm re-authentication with a new API key."""
errors: dict[str, str] = {}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
errors = await self._validate_api_key(api_key)
if not errors:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: api_key},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
@@ -12,7 +12,7 @@ from fluss_api import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -60,7 +60,7 @@ class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
raise ConfigEntryError(f"Authentication failed: {err}") from err
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
@@ -29,7 +29,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: todo
# Gold
entity-translations: done
+11 -1
View File
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,15 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "The API key found in the profile page of the Fluss+ app."
},
"description": "The Fluss+ API key is no longer valid. Get your API key from the profile page of the Fluss+ app, or generate a new one, and enter it below."
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["foobot_async"],
"quality_scale": "legacy",
"requirements": ["foobot_async==1.0.0"]
"requirements": ["foobot_async==1.0.1"]
}
@@ -65,14 +65,16 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("Discovered device: %s", discovery_info)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
mfg = await async_get_product(self.hass, discovery_info.address)
self.devices[discovery_info.address] = mfg
if mfg.product_type not in _SUPPORTED_PRODUCT_TYPES:
return self.async_abort(reason="no_devices_found")
self.address = discovery_info.address
await self.async_set_unique_id(self.address)
self._abort_if_unique_id_configured()
return await self.async_step_confirm()
async def async_step_confirm(
@@ -230,11 +230,19 @@ class GoogleGenerativeAISttEntity(
f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}",
)
prompt = self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
if metadata.language:
prompt = (
f"{prompt}\n"
f"The spoken language is {metadata.language}. "
f"Transcribe in that language."
)
try:
response = await self._genai_client.aio.models.generate_content(
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
contents=[
self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
prompt,
Part.from_bytes(
data=audio_data,
mime_type=f"audio/{metadata.format.value}",
+1 -1
View File
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/gree",
"iot_class": "local_polling",
"loggers": ["greeclimate"],
"requirements": ["greeclimate==2.1.1"]
"requirements": ["greeclimate==2.1.4"]
}
@@ -1,15 +1,132 @@
"""Green Planet Energy integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from datetime import timedelta
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .coordinator import GreenPlanetEnergyUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type GreenPlanetEnergyConfigEntry = ConfigEntry[GreenPlanetEnergyUpdateCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]
# Service constants
SERVICE_GET_CHEAPEST_DURATION = "get_cheapest_duration"
ATTR_DURATION = "duration"
ATTR_TIME_RANGE = "time_range"
# Time range options
TIME_RANGE_DAY = "day"
TIME_RANGE_NIGHT = "night"
TIME_RANGE_FULL_DAY = "full_day"
SERVICE_GET_CHEAPEST_DURATION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DURATION): vol.All(
vol.Coerce(float), vol.Range(min=0.5, max=24)
),
vol.Optional(ATTR_TIME_RANGE, default=TIME_RANGE_FULL_DAY): vol.In(
[TIME_RANGE_DAY, TIME_RANGE_NIGHT, TIME_RANGE_FULL_DAY]
),
}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Green Planet Energy component."""
async def get_cheapest_duration(call: ServiceCall) -> ServiceResponse:
"""Handle the get_cheapest_duration service call."""
# This integration has single_config_entry, so get the first entry
entries = hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
entry = entries[0]
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
coordinator: GreenPlanetEnergyUpdateCoordinator = entry.runtime_data
duration = call.data[ATTR_DURATION]
time_range = call.data[ATTR_TIME_RANGE]
data = coordinator.data
api = coordinator.api
now = dt_util.now()
current_hour = now.hour
result: tuple[float | None, int | None]
if time_range == TIME_RANGE_DAY:
result = api.get_cheapest_duration_day(data, duration, current_hour)
elif time_range == TIME_RANGE_NIGHT:
result = api.get_cheapest_duration_night(data, duration, current_hour)
else: # TIME_RANGE_FULL_DAY
result = api.get_cheapest_duration(data, duration, current_hour)
avg_price, start_hour_result = result
if avg_price is None or start_hour_result is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_data_available",
)
start_time = dt_util.start_of_local_day(now).replace(
hour=start_hour_result, minute=0, second=0, microsecond=0
)
# If the calculated start time is in the past, shift to tomorrow
if start_time < now:
start_time = start_time + timedelta(days=1)
end_time = start_time + timedelta(hours=duration)
hours_until_start = (start_time - now).total_seconds() / 3600
return {
"duration": duration,
"average_price": round(avg_price / 100, 4),
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"hours_until_start": round(hours_until_start, 1),
"time_range": time_range,
}
hass.services.async_register(
DOMAIN,
SERVICE_GET_CHEAPEST_DURATION,
get_cheapest_duration,
schema=SERVICE_GET_CHEAPEST_DURATION_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
return True
async def async_setup_entry(
hass: HomeAssistant, entry: GreenPlanetEnergyConfigEntry
@@ -21,6 +138,7 @@ async def async_setup_entry(
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -0,0 +1,7 @@
{
"services": {
"get_cheapest_duration": {
"service": "mdi:clock-check"
}
}
}
@@ -0,0 +1,25 @@
# Describes the format for available Green Planet Energy services
get_cheapest_duration:
fields:
duration:
required: true
example: 2.5
selector:
number:
min: 0.5
max: 24
step: 0.25
unit_of_measurement: "h"
time_range:
required: false
default: "full_day"
selector:
select:
options:
- label: Full day (00:00-24:00)
value: "full_day"
- label: Day (06:00-18:00)
value: "day"
- label: Night (18:00-06:00)
value: "night"
@@ -43,8 +43,33 @@
"api_error": {
"message": "API error: {error}"
},
"config_entry_not_loaded": {
"message": "This integration instance is not currently loaded"
},
"connection_error": {
"message": "Connection error: {error}"
},
"no_config_entry": {
"message": "No matching integration instance was found"
},
"no_data_available": {
"message": "No price data available for the requested duration and time range"
}
},
"services": {
"get_cheapest_duration": {
"description": "Retrieve electricity price data and find the cheapest consecutive time window for a given duration.",
"fields": {
"duration": {
"description": "Duration in hours for which to find the cheapest time window.",
"name": "Duration"
},
"time_range": {
"description": "Time range to search within.",
"name": "Time range"
}
},
"name": "Get cheapest duration"
}
}
}
@@ -0,0 +1,91 @@
"""Home Assistant integration for Greencell EVSE devices."""
import asyncio
from collections.abc import Callable
import json
import logging
from greencell_client.access import GreencellAccess, GreencellHaAccessLevel
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from .const import CONF_SERIAL_NUMBER, DISCOVERY_TIMEOUT, GREENCELL_DISC_TOPIC
from .models import GreencellConfigEntry, GreencellRuntimeData
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
def make_ready_handler(
serial: str, event: asyncio.Event
) -> Callable[[ReceiveMessage], None]:
"""Create an MQTT message handler that sets event when device matches serial."""
@callback
def _on_message(message: ReceiveMessage) -> None:
if event.is_set():
return
try:
data = json.loads(message.payload)
except ValueError, TypeError:
return
if message.topic == GREENCELL_DISC_TOPIC:
if data.get("id") != serial:
return
elif data.get("id") and data["id"] != serial:
return
event.set()
return _on_message
async def async_setup_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Set up Greencell from a config entry."""
if not await mqtt.async_wait_for_mqtt_client(hass):
raise ConfigEntryNotReady("MQTT integration is not available")
serial: str = entry.data[CONF_SERIAL_NUMBER]
device_ready_event = asyncio.Event()
on_message = make_ready_handler(serial, device_ready_event)
try:
unsub_disc = await mqtt.async_subscribe(hass, GREENCELL_DISC_TOPIC, on_message)
unsub_volt = await mqtt.async_subscribe(
hass, f"/greencell/evse/{serial}/voltage", on_message
)
try:
async with asyncio.timeout(DISCOVERY_TIMEOUT):
await device_ready_event.wait()
finally:
unsub_disc()
unsub_volt()
except TimeoutError as err:
raise ConfigEntryNotReady(f"No initial data from device {serial}") from err
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT error: {err}") from err
entry.runtime_data = GreencellRuntimeData(
access=GreencellAccess(GreencellHaAccessLevel.EXECUTE),
current_data=ElecData3Phase(),
voltage_data=ElecData3Phase(),
power_data=ElecDataSinglePhase(),
state_data=ElecDataSinglePhase(),
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: GreencellConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,192 @@
"""Config flow for Greencell EVSE integration in Home Assistant."""
import asyncio
from collections.abc import Callable
import json
import logging
from typing import Any
from greencell_client.utils import GreencellUtils
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from . import const
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_BROADCAST_TOPIC,
GREENCELL_DISC_TOPIC,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
)
_LOGGER = logging.getLogger(__name__)
class EVSEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Greencell EVSE devices."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, dict[str, Any]] = {}
self._discovered_serial: str | None = None
self._discovery_event: asyncio.Event | None = None
self._remove_listener: Callable | None = None
def _get_device_name(self, serial: str) -> str:
"""Determine the device name based on the serial number."""
return (
GREENCELL_HABU_DEN
if GreencellUtils.device_is_habu_den(serial)
else GREENCELL_OTHER_DEVICE
)
@callback
def _async_mqtt_message_received(self, msg: ReceiveMessage) -> None:
"""Handle incoming MQTT messages on the discovery topic."""
try:
payload = json.loads(msg.payload)
except json.JSONDecodeError, AttributeError:
return
serial = payload.get("id")
if isinstance(serial, str) and serial.strip():
self._discovered[serial] = payload
if self._discovery_event:
self._discovery_event.set()
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> config_entries.ConfigFlowResult:
"""Handle a flow initialized by MQTT discovery."""
try:
payload = json.loads(discovery_info.payload)
serial = payload.get("id")
except json.JSONDecodeError, AttributeError:
return self.async_abort(reason="invalid_discovery_data")
if not isinstance(serial, str) or not serial.strip():
return self.async_abort(reason="invalid_discovery_data")
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
self._discovered_serial = serial
device_name = self._get_device_name(serial)
self.context.update({"title_placeholders": {"name": f"{device_name} {serial}"}})
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Confirm addition of a discovered device."""
assert self._discovered_serial is not None
serial = self._discovered_serial
if user_input is not None:
return self.async_create_entry(
title=f"{self._get_device_name(serial)} {serial}",
data={CONF_SERIAL_NUMBER: serial},
)
return self.async_show_form(
step_id="confirm",
description_placeholders={"serial": serial},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Manual step: start active discovery process."""
try:
if not mqtt.is_connected(self.hass):
return self.async_abort(reason="mqtt_not_connected")
except KeyError:
return self.async_abort(reason="mqtt_not_configured")
return await self.async_step_discover()
async def async_step_discover(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Discovery step: subscribe, broadcast, and wait for responses."""
self._discovery_event = asyncio.Event()
try:
self._remove_listener = await mqtt.async_subscribe(
self.hass,
GREENCELL_DISC_TOPIC,
self._async_mqtt_message_received,
)
except HomeAssistantError, ValueError:
return self.async_abort(reason="mqtt_subscription_failed")
try:
payload = json.dumps({"name": "BROADCAST"})
await mqtt.async_publish(
self.hass, GREENCELL_BROADCAST_TOPIC, payload, qos=0, retain=False
)
try:
await asyncio.wait_for(
self._discovery_event.wait(), timeout=const.DISCOVERY_TIMEOUT
)
# Grace period for additional devices
await asyncio.sleep(0.5)
except TimeoutError:
_LOGGER.debug("Discovery timed out waiting for device responses")
finally:
self._remove_listener()
if not self._discovered:
return self.async_abort(reason="no_discovery_data")
if len(self._discovered) == 1:
serial = next(iter(self._discovered))
return await self._async_create_entry(serial)
return await self.async_step_select()
async def async_step_select(
self, user_input: dict[str, Any] | None = None
) -> config_entries.ConfigFlowResult:
"""Let the user select one of the discovered devices."""
if user_input is not None:
serial = user_input[CONF_SERIAL_NUMBER]
return await self._async_create_entry(serial)
return self.async_show_form(
step_id="select",
data_schema=vol.Schema(
{
vol.Required(CONF_SERIAL_NUMBER): vol.In(
list(self._discovered.keys())
)
}
),
description_placeholders={"count": str(len(self._discovered))},
)
async def _async_create_entry(self, serial: str) -> config_entries.ConfigFlowResult:
"""Finalize entry creation for selected device."""
await self.async_set_unique_id(serial, raise_on_progress=False)
self._abort_if_unique_id_configured()
device_name = self._get_device_name(serial)
title = f"{device_name} {serial}"
_LOGGER.info("Discovered and added device: %s", title)
return self.async_create_entry(
title=title,
data={CONF_SERIAL_NUMBER: serial},
)
@@ -0,0 +1,31 @@
"""Core constants for the Greencell EVSE Home Assistant integration."""
from typing import Final
# Greencell constants
DOMAIN = "greencell"
MANUFACTURER: Final = "Greencell"
# Maximal current configuration
DEFAULT_MIN_CURRENT = 6
DEFAULT_MAX_CURRENT_OTHER = 16
DEFAULT_MAX_CURRENT_HABU_DEN = 32
# Topics
GREENCELL_BROADCAST_TOPIC = "/greencell/broadcast"
GREENCELL_DISC_TOPIC = "/greencell/broadcast/device"
# Device names
GREENCELL_HABU_DEN = "Habu Den"
GREENCELL_OTHER_DEVICE = "Greencell Device"
# Other constants
DISCOVERY_MIN_TIMEOUT = 5.0
DISCOVERY_TIMEOUT = 30.0
SET_CURRENT_RETRY_TIME = 15
CONF_SERIAL_NUMBER = "serial_number"
@@ -0,0 +1,30 @@
{
"entity": {
"sensor": {
"current_l1": {
"default": "mdi:flash"
},
"current_l2": {
"default": "mdi:flash"
},
"current_l3": {
"default": "mdi:flash"
},
"power": {
"default": "mdi:battery-charging-high"
},
"status": {
"default": "mdi:ev-plug-type2"
},
"voltage_l1": {
"default": "mdi:meter-electric"
},
"voltage_l2": {
"default": "mdi:meter-electric"
},
"voltage_l3": {
"default": "mdi:meter-electric"
}
}
}
}
@@ -0,0 +1,13 @@
{
"domain": "greencell",
"name": "Greencell",
"codeowners": ["@BrzezowskiGC"],
"config_flow": true,
"dependencies": ["mqtt"],
"documentation": "https://www.home-assistant.io/integrations/greencell",
"integration_type": "device",
"iot_class": "local_push",
"mqtt": ["/greencell/broadcast/device"],
"quality_scale": "bronze",
"requirements": ["greencell_client==1.0.3"]
}
@@ -0,0 +1,22 @@
"""Type definitions for Greencell integration."""
from dataclasses import dataclass
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from homeassistant.config_entries import ConfigEntry
@dataclass
class GreencellRuntimeData:
"""Runtime data for Greencell integration."""
access: GreencellAccess
current_data: ElecData3Phase
voltage_data: ElecData3Phase
power_data: ElecDataSinglePhase
state_data: ElecDataSinglePhase
type GreencellConfigEntry = ConfigEntry[GreencellRuntimeData]
@@ -0,0 +1,63 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions or services.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
@@ -0,0 +1,341 @@
"""Home Assistant integration module for Greencell EVSE sensor entities over MQTT."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from greencell_client.access import GreencellAccess
from greencell_client.elec_data import ElecData3Phase, ElecDataSinglePhase
from greencell_client.mqtt_parser import MqttParser
from greencell_client.utils import GreencellUtils
from homeassistant.components import mqtt
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
CONF_SERIAL_NUMBER,
DOMAIN,
GREENCELL_HABU_DEN,
GREENCELL_OTHER_DEVICE,
MANUFACTURER,
)
from .models import GreencellConfigEntry
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GreencellSensorDescription(SensorEntityDescription):
"""Describe a Greencell sensor."""
value_fn: Callable[[Any], StateType]
SENSOR_DESCRIPTIONS = (
GreencellSensorDescription(
key="current_l1",
translation_key="current_l1",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l2",
translation_key="current_l2",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="current_l3",
translation_key="current_l3",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
suggested_display_precision=3,
value_fn=lambda data: data / 1000,
),
GreencellSensorDescription(
key="voltage_l1",
translation_key="voltage_l1",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l2",
translation_key="voltage_l2",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="voltage_l3",
translation_key="voltage_l3",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="power",
translation_key="power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=1,
value_fn=lambda data: data,
),
GreencellSensorDescription(
key="status",
translation_key="status",
device_class=SensorDeviceClass.ENUM,
options=[
"idle",
"connected",
"waiting_for_car",
"charging",
"finished",
"error_car",
"error_evse",
],
value_fn=lambda data: str(data).lower() if isinstance(data, str) else None,
),
)
# --- Config Flow Setup ---
async def async_setup_entry(
hass: HomeAssistant,
entry: GreencellConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Greencell EVSE sensors from a config entry."""
serial_number: str = entry.data[CONF_SERIAL_NUMBER]
mqtt_topic_current = f"/greencell/evse/{serial_number}/current"
mqtt_topic_voltage = f"/greencell/evse/{serial_number}/voltage"
mqtt_topic_power = f"/greencell/evse/{serial_number}/power"
mqtt_topic_status = f"/greencell/evse/{serial_number}/status"
mqtt_topic_device_state = f"/greencell/evse/{serial_number}/device_state"
desc_map = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
runtime = entry.runtime_data
access = runtime.access
current_data_obj = runtime.current_data
voltage_data_obj = runtime.voltage_data
power_data_obj = runtime.power_data
state_data_obj = runtime.state_data
data_mapping = {
"current": current_data_obj,
"voltage": voltage_data_obj,
"power": power_data_obj,
"status": state_data_obj,
}
sensors: list[HabuSensor] = [
Habu3PhaseSensor(
sensor_data=data_mapping[description.key.split("_")[0]],
phase=description.key.split("_")[-1],
sensor_type=description.key,
serial_number=serial_number,
access=access,
description=description,
)
for description in SENSOR_DESCRIPTIONS
if description.key.startswith(("current_l", "voltage_l"))
]
sensors.extend(
HabuSingleSensor(
sensor_data=data_mapping[key],
serial_number=serial_number,
sensor_type=key,
access=access,
description=desc_map[key],
)
for key in ("power", "status")
)
@callback
def current_message_received(msg: ReceiveMessage) -> None:
"""Handle the current message."""
MqttParser.parse_3phase_msg(msg.payload, current_data_obj)
@callback
def voltage_message_received(msg: ReceiveMessage) -> None:
"""Handle the voltage message."""
MqttParser.parse_3phase_msg(msg.payload, voltage_data_obj)
@callback
def power_message_received(msg: ReceiveMessage) -> None:
"""Handle the power message."""
MqttParser.parse_single_phase_msg(msg.payload, "momentary", power_data_obj)
@callback
def status_message_received(msg: ReceiveMessage) -> None:
"""Handle the status message. If the device is unavailable, disable the entity."""
str_payload = (
msg.payload.decode("utf-8", errors="ignore")
if isinstance(msg.payload, (bytes, bytearray))
else str(msg.payload)
)
if "UNAVAILABLE" in str_payload or "OFFLINE" in str_payload:
access.update("UNAVAILABLE")
else:
MqttParser.parse_single_phase_msg(msg.payload, "state", state_data_obj)
@callback
def device_state_message_received(msg: ReceiveMessage) -> None:
"""Handle the device state message. If device was unavailable, enable the entity."""
access.on_msg(msg.payload)
try:
for topic, handler in (
(mqtt_topic_current, current_message_received),
(mqtt_topic_voltage, voltage_message_received),
(mqtt_topic_power, power_message_received),
(mqtt_topic_status, status_message_received),
(mqtt_topic_device_state, device_state_message_received),
):
unsub = await mqtt.async_subscribe(hass, topic, handler)
if unsub is not None:
entry.async_on_unload(unsub)
except HomeAssistantError as err:
raise ConfigEntryNotReady(f"MQTT is unavailable: {err}") from err
async_add_entities(sensors)
class HabuSensor(SensorEntity):
"""Abstract base class for Habu sensors integration."""
entity_description: GreencellSensorDescription
_attr_has_entity_name = True
_remove_listener: Callable[[], None] | None = None
def __init__(
self,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the sensor entity."""
self._sensor_type = sensor_type
self._serial_number = serial_number
self._access = access
self.entity_description = description
self._attr_unique_id = f"{serial_number}_{description.key}"
if GreencellUtils.device_is_habu_den(self._serial_number):
device_name = GREENCELL_HABU_DEN
else:
device_name = GREENCELL_OTHER_DEVICE
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=f"{device_name} {serial_number}",
manufacturer=MANUFACTURER,
model=device_name,
serial_number=serial_number,
)
@property
def available(self) -> bool:
"""Return True if the entity is available."""
return not self._access.is_disabled()
async def async_added_to_hass(self) -> None:
"""Register the entity with Home Assistant."""
unsub = self._access.register_listener(self._schedule_update)
if unsub is not None:
self.async_on_remove(unsub)
def _schedule_update(self) -> None:
"""Schedule an update for the entity."""
self.async_schedule_update_ha_state()
class Habu3PhaseSensor(HabuSensor):
"""Abstract class for 3-phase sensors (e.g. current, voltage)."""
def __init__(
self,
sensor_data: ElecData3Phase,
phase: str,
sensor_type: str,
serial_number: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the 3-phase sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._sensor_data = sensor_data
self._phase = phase
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._sensor_data.get_value(self._phase)
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
class HabuSingleSensor(HabuSensor):
"""Example class for sensors that return a single value."""
def __init__(
self,
sensor_data: ElecDataSinglePhase,
serial_number: str,
sensor_type: str,
access: GreencellAccess,
description: GreencellSensorDescription,
) -> None:
"""Initialize the single-value sensor."""
super().__init__(sensor_type, serial_number, access, description)
self._value = sensor_data
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
raw_value = self._value.data
if raw_value is None:
return None
return self.entity_description.value_fn(raw_value)
@@ -0,0 +1,70 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"invalid_discovery_data": "The received discovery data is invalid.",
"mqtt_not_configured": "MQTT is not configured. Please configure MQTT first.",
"mqtt_not_connected": "MQTT is not connected. Ensure the MQTT broker is running and configured.",
"mqtt_subscription_failed": "Failed to subscribe to the MQTT topic for discovery.",
"no_discovery_data": "No discovery data received. Ensure the device is online and broadcasting."
},
"step": {
"confirm": {
"description": "A Greencell device with serial number {serial} was discovered. Do you want to add it?",
"title": "Greencell device discovered"
},
"select": {
"data": {
"serial_number": "Device serial number"
},
"data_description": {
"serial_number": "Select the device you want to add to Home Assistant"
},
"description": "Multiple Greencell devices were found (total: {count}). Please choose which one you want to configure.",
"title": "Select your device"
},
"user": {
"description": "The integration will try to discover your EVSE devices over MQTT.",
"title": "Set up your Greencell HabuDen EVSE"
}
}
},
"entity": {
"sensor": {
"current_l1": {
"name": "Current phase L1"
},
"current_l2": {
"name": "Current phase L2"
},
"current_l3": {
"name": "Current phase L3"
},
"power": {
"name": "Power"
},
"status": {
"name": "Status",
"state": {
"charging": "[%key:common::state::charging%]",
"connected": "[%key:common::state::connected%]",
"error_car": "Car error",
"error_evse": "EVSE error",
"finished": "Finished",
"idle": "[%key:common::state::idle%]",
"waiting_for_car": "Waiting for car"
}
},
"voltage_l1": {
"name": "Voltage phase L1"
},
"voltage_l2": {
"name": "Voltage phase L2"
},
"voltage_l3": {
"name": "Voltage phase L3"
}
}
}
}
@@ -201,7 +201,8 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
# V1 API returns current_power in kW, convert to W
total_info["invTodayPpv"] = total_info["current_power"] * 1000
else:
# Classic API: use plant_info as before.
# Copy the response to avoid mutating the dict returned by the library
+1 -1
View File
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pygtfs"],
"quality_scale": "legacy",
"requirements": ["pygtfs==0.1.9"]
"requirements": ["pygtfs==0.1.11"]
}
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["heatmiserV3"],
"quality_scale": "legacy",
"requirements": ["heatmiserV3==2.0.4"]
"requirements": ["heatmiserV3==2.0.6"]
}
@@ -15,7 +15,7 @@
},
"title": "[%key:component::here_travel_time::config::step::destination_menu::title%]"
},
"destination_entity_id": {
"destination_entity": {
"data": {
"destination_entity_id": "Destination using an entity"
},
@@ -34,7 +34,7 @@
},
"title": "[%key:component::here_travel_time::config::step::origin_menu::title%]"
},
"origin_entity_id": {
"origin_entity": {
"data": {
"origin_entity_id": "Origin using an entity"
},
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyhik"],
"requirements": ["pyHik==0.4.2"]
"requirements": ["pyHik==0.4.3"]
}
+1 -1
View File
@@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HiveConfigEntry) -> bool
hub_data = devices["parent"][0]
connections: set[tuple[str, str]] = set()
if mac := hub_data.get("macAddress"):
connections.add((dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac)))
connections.add((dr.CONNECTION_NETWORK_MAC, mac))
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
@@ -12,8 +12,7 @@
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your Hi-Link HLK-SW-16 device."
+1 -3
View File
@@ -83,9 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={
(dr.CONNECTION_NETWORK_MAC, dr.format_mac(homee.settings.mac_address))
},
connections={(dr.CONNECTION_NETWORK_MAC, homee.settings.mac_address)},
identifiers={(DOMAIN, homee.settings.uid)},
manufacturer="homee",
name=homee.settings.homee_name,
@@ -48,7 +48,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: todo
@@ -177,7 +177,7 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Determine if the device is a homekit bridge or accessory."""
dev_reg = dr.async_get(self.hass)
device = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))}
connections={(dr.CONNECTION_NETWORK_MAC, hkid)}
)
if device is None:
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["pyhomematic"],
"quality_scale": "legacy",
"requirements": ["pyhomematic==0.1.77"]
"requirements": ["pyhomematic==0.1.78"]
}
@@ -22,4 +22,7 @@ async def async_get_config_entry_diagnostics(
anonymized = handle_config(json_state, anonymize=True)
config = json.loads(anonymized)
return async_redact_data(config, TO_REDACT_CONFIG)
return {
"websocket": hap.websocket_diagnostics(),
"config": async_redact_data(config, TO_REDACT_CONFIG),
}
@@ -164,9 +164,11 @@ class HomematicipHAP:
self.set_all_to_unavailable()
elif self._ws_connection_closed.is_set():
_LOGGER.info("HMIP access point has reconnected to the cloud")
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._start_get_state_task()
@callback
def async_create_entity(self, *args, **kwargs) -> None:
@@ -180,44 +182,103 @@ class HomematicipHAP:
await asyncio.sleep(30)
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
def websocket_diagnostics(self) -> dict[str, Any]:
"""Return websocket diagnostics dict (None values omitted)."""
diagnostics = {
"last_disconnect_reason": self.home.websocket_last_disconnect_reason(),
"reconnect_attempts": self.home.websocket_reconnect_attempt_count(),
"seconds_since_last_message": (
self.home.websocket_seconds_since_last_message()
),
"message_count": self.home.websocket_message_count(),
}
return {k: v for k, v in diagnostics.items() if v is not None}
def _websocket_diagnostic_context(self) -> str:
"""Return a single-line summary of websocket diagnostics for logs."""
diagnostics = self.websocket_diagnostics()
if not diagnostics:
return "no diagnostics available"
return ", ".join(f"{k}={v!r}" for k, v in diagnostics.items())
@callback
def _start_get_state_task(self) -> None:
"""Cancel any in-flight reconnect refresh and start a new one."""
if self._get_state_task is not None and not self._get_state_task.done():
_LOGGER.debug(
"Cancelling previous HomematicIP reconnect state refresh task"
)
self._get_state_task.cancel()
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
async def _try_get_state(self) -> None:
"""Call get_state in a loop until no error occurs.
"""Refresh state after a websocket reconnect.
Uses exponential backoff on error.
Delegates the bounded websocket wait + retry-with-exponential-backoff
to the homematicip library (``refresh_state_after_reconnect_async``),
and only handles HA-specific concerns here:
- on authentication failure, trigger reauth
- clear the per-device ``unreach`` flag and signal entity updates
(the workaround for core#160048)
"""
try:
await self.home.refresh_state_after_reconnect_async()
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
return
self._post_state_refresh()
# Wait until WebSocket connection is established.
while not self.home.websocket_is_connected():
await asyncio.sleep(2)
async def _on_websocket_stale(self, severity: str, seconds_since: float) -> None:
"""Log a websocket-stale event surfaced by the library.
delay = 8
max_delay = 1500
while True:
try:
await self.get_state()
break
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err
)
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
The library polls staleness internally and fires this callback once
per severity per stuck period; it re-arms when fresh messages arrive.
We just translate severity to a log level.
"""
log = _LOGGER.error if severity == "error" else _LOGGER.warning
log(
"HomematicIP websocket has not received a message for "
"%.0f seconds while reporting connected",
seconds_since,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
await self.home.get_current_state_async()
self._post_state_refresh()
def _post_state_refresh(self) -> None:
"""Apply HA-specific post-processing after a state refresh.
``set_all_to_unavailable`` marked every device unreach=True on
disconnect; ``get_current_state_async`` only clears that flag for
devices whose state actually changed during the outage, so the rest
stay stuck unavailable after reconnect. Force-clear for all devices.
Trade-off: a device that is *genuinely* unreachable on the cloud
side will briefly appear available until its next state push
corrects it. That self-corrects, while the previous behaviour left
entities stuck unavailable indefinitely (core #160048).
"""
for device in self.home.devices:
device.unreach = False
self.update_all()
def get_state_finished(self, future) -> None:
"""Execute when try_get_state coroutine has finished."""
try:
future.result()
except asyncio.CancelledError:
_LOGGER.debug("HomematicIP reconnect state refresh task was cancelled")
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error updating state after HMIP access point reconnect: %s", err
@@ -246,6 +307,7 @@ class HomematicipHAP:
home.set_on_connected_handler(self.ws_connected_handler)
home.set_on_disconnected_handler(self.ws_disconnected_handler)
home.set_on_reconnect_handler(self.ws_reconnected_handler)
home.set_on_websocket_stale_handler(self._on_websocket_stale)
async def async_reset(self) -> bool:
"""Close the websocket connection."""
@@ -275,23 +337,28 @@ class HomematicipHAP:
"""Handle websocket connected."""
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set():
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
self._start_get_state_task()
async def ws_disconnected_handler(self) -> None:
"""Handle websocket disconnection."""
_LOGGER.warning("Websocket connection to HomematicIP Cloud closed")
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection."""
_LOGGER.info(
"Websocket connection to HomematicIP Cloud trying"
" to reconnect due to reason: %s",
"Websocket connection to HomematicIP Cloud trying to reconnect due to "
"reason: %s",
reason,
)
_LOGGER.debug(
"HMIP websocket diagnostics: %s",
self._websocket_diagnostic_context(),
)
self._ws_connection_closed.set()
@@ -10,7 +10,8 @@
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "The Honeywell integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
@@ -6,7 +6,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -41,6 +41,7 @@ class IAlarmPanel(
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.mac)},
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
manufacturer="Antifurto365 - Meian",
name="iAlarm",
)
+3 -1
View File
@@ -18,6 +18,7 @@ from .const import (
STORAGE_KEY,
STORAGE_VERSION,
)
from .media_source import async_setup_mediasource, async_setup_photo_cache
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -27,7 +28,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up iCloud integration."""
async_setup_services(hass)
async_setup_mediasource(hass)
return True
@@ -61,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
entry.runtime_data = account
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await async_setup_photo_cache(hass, account)
return True
+6 -1
View File
@@ -3,7 +3,7 @@
from datetime import timedelta
import logging
import operator
from typing import Any
from typing import TYPE_CHECKING, Any
from pyicloud import PyiCloudService
from pyicloud.exceptions import (
@@ -55,6 +55,9 @@ from .const import (
DOMAIN,
)
if TYPE_CHECKING:
from .media_source import PhotoCache
_LOGGER = logging.getLogger(__name__)
type IcloudConfigEntry = ConfigEntry[IcloudAccount]
@@ -95,6 +98,8 @@ class IcloudAccount:
self._unsub_fetch: CALLBACK_TYPE | None = None
self.listeners: list[CALLBACK_TYPE] = []
self.photo_cache: PhotoCache | None = None
def setup(self) -> None:
"""Set up an iCloud account."""
try:
@@ -3,9 +3,10 @@
"name": "Apple iCloud",
"codeowners": ["@Quentame", "@nzapponi"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/icloud",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.4.1"]
"requirements": ["pyicloud==2.6.5"]
}
@@ -0,0 +1,671 @@
"""Expose iCloud photo albums as a media source."""
from base64 import b64decode, b64encode
import binascii
from collections import OrderedDict
from dataclasses import dataclass
import logging
import threading
import urllib.parse
from aiohttp import ClientTimeout, hdrs, web
from pyicloud.services.photos import (
AlbumContainer,
BasePhotoAlbum,
PhotoAlbumFolder,
PhotoAsset,
)
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.static import CACHE_HEADERS
from homeassistant.components.media_player import BrowseError, MediaClass, MediaType
from homeassistant.components.media_source import (
BrowseMediaSource,
MediaSource,
MediaSourceItem,
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .account import IcloudAccount
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
MAX_PHOTO_CACHE_SIZE = 1000
def async_setup_mediasource(hass: HomeAssistant) -> None:
"""Set up the iCloud media source."""
hass.http.register_view(IcloudMediaSourceView(hass))
async def async_get_media_source(hass: HomeAssistant) -> IcloudMediaSource:
"""Set up iCloud media source."""
return IcloudMediaSource(hass)
def _get_icloud_account_and_title(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> tuple[IcloudAccount, str]:
"""Get iCloud account from identifier. Also return the account title for display purposes."""
entry = hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, identifier.config_entry_id
)
if entry is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"entry": identifier.config_entry_id},
)
if getattr(entry, "runtime_data", None) is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return entry.runtime_data, entry.title
async def async_setup_photo_cache(hass, account):
"""Set up the photo cache for the iCloud account."""
if account.photo_cache is None:
account.photo_cache = PhotoCache()
async def _get_photo_library(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> AlbumContainer:
"""Get photo library."""
def get_photo_library_sync() -> AlbumContainer:
"""Get photo library synchronously."""
if icloud_account.api is None or icloud_account.api.photos is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
return (
icloud_account.api.photos.shared_streams
if identifier.shared_album is True
else icloud_account.api.photos.albums
)
return await hass.async_add_executor_job(get_photo_library_sync)
async def _get_photo_album(
hass: HomeAssistant,
icloud_account: IcloudAccount,
identifier: IcloudMediaSourceIdentifier,
) -> BasePhotoAlbum:
"""Get photo album from identifier."""
def _find_album_sync() -> BasePhotoAlbum | None:
"""Find album synchronously."""
album: BasePhotoAlbum | None = (
albums.get(identifier.album_id) if albums and identifier.album_id else None
)
if not album:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="album_not_found",
)
return album
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(hass, icloud_account, identifier)
return await hass.async_add_executor_job(_find_album_sync)
async def _get_photo_asset(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> PhotoAsset:
"""Get photo asset asynchronously."""
def _get_photo_asset_sync(album: BasePhotoAlbum) -> PhotoAsset | None:
"""Get photo asset synchronously."""
for item in album.photos:
if item.id == identifier.photo_id and identifier.photo_id is not None:
PhotoCache.instance(icloud_account).set(identifier.photo_id, item)
return item
return None
icloud_account, _ = _get_icloud_account_and_title(hass, identifier)
if identifier.album_id is None or identifier.photo_id is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
photo: PhotoAsset | None = await hass.async_add_executor_job(
PhotoCache.instance(icloud_account).get, identifier.photo_id
)
if photo is None:
album: BasePhotoAlbum = await _get_photo_album(hass, icloud_account, identifier)
photo = await hass.async_add_executor_job(_get_photo_asset_sync, album)
if photo is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="photo_not_found",
)
return photo
async def _get_media_mime_type(
hass: HomeAssistant, identifier: IcloudMediaSourceIdentifier
) -> str:
"""Get media MIME type asynchronously."""
photo: PhotoAsset = await _get_photo_asset(hass, identifier)
match photo.item_type:
case "image":
if photo.filename.lower().endswith(".png"):
return "image/png"
if photo.filename.lower().endswith(".heic"):
return "image/heic"
return "image/jpeg"
case "movie":
return "video/mp4"
case _:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="unsupported_media_type",
)
class PhotoCache:
"""Simple in-memory cache for PhotoAsset objects."""
@classmethod
def instance(cls, icloud_account: IcloudAccount) -> PhotoCache:
"""Get the account instance of the photo cache."""
if icloud_account.photo_cache is None:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
return icloud_account.photo_cache
def __init__(self, max_size: int = MAX_PHOTO_CACHE_SIZE) -> None:
"""Initialize the photo cache."""
self._cache: OrderedDict[str, PhotoAsset] = OrderedDict()
self._max_size = max_size
self._lock = threading.RLock()
def get(self, photo_id: str) -> PhotoAsset | None:
"""Get a photo from the cache."""
with self._lock:
photo = self._cache.get(photo_id)
if photo is not None:
# Move the accessed item to the end to show that it was recently used
self._cache.move_to_end(photo_id)
return photo
def set(self, photo_id: str, photo: PhotoAsset) -> None:
"""Set a photo in the cache."""
with self._lock:
self._cache[photo_id] = photo
if len(self._cache) > self._max_size:
self._cache.popitem(last=False)
@dataclass(kw_only=True)
class IcloudMediaSourceIdentifier:
"""Parse and represent an iCloud media source identifier.
Example identifier format: config_entry_id/album/album_id
Example identifier format: config_entry_id/shared/shared_album_id
Example identifier format: config_entry_id/album/album_id/photo_id
Example identifier format: config_entry_id/shared/shared_album_id/photo_id
"""
config_entry_id: str
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
@staticmethod
def from_identifier(identifier: str) -> IcloudMediaSourceIdentifier:
"""Initialize iCloud media source identifier."""
config_entry_id: str = ""
shared_album: bool | None = None
album_id: str | None = None
photo_id: str | None = None
parts: list[str] = identifier.split("/") if identifier else []
for idx, part in enumerate(parts):
if idx == 0:
config_entry_id = part
elif idx == 1:
if part.lower() not in ("shared", "album"):
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="invalid_view_type",
)
shared_album = part.lower() == "shared"
elif idx == 2:
album_id = part
elif idx == 3:
photo_id = part
if not config_entry_id:
raise Unresolvable(
translation_domain=DOMAIN,
translation_key="incomplete_media_source_identifier",
)
return IcloudMediaSourceIdentifier(
config_entry_id=config_entry_id,
shared_album=shared_album,
album_id=album_id,
photo_id=photo_id,
)
def __str__(self) -> str:
"""Return string representation of the identifier."""
parts = [self.config_entry_id]
if self.shared_album is not None:
parts.append("shared" if self.shared_album else "album")
if self.album_id is not None:
parts.append(self.album_id)
if self.photo_id is not None:
parts.append(self.photo_id)
return "/".join(parts)
class IcloudMediaSource(MediaSource):
"""Provide iCloud media source."""
name = "iCloud"
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source."""
super().__init__(DOMAIN)
self._hass = hass
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve a media item to a playable object."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
mime_type = await _get_media_mime_type(self._hass, identifier)
return PlayMedia(
f"/api/icloud/media_source/serve/original/{b64encode(str(item.identifier).encode()).decode()}",
mime_type,
)
def _get_config_entries(self) -> list[ConfigEntry]:
"""Get iCloud config entries."""
return self._hass.config_entries.async_entries(
DOMAIN, include_disabled=False, include_ignore=False
)
async def _build_title_for_identifier(
self,
identifier: IcloudMediaSourceIdentifier | None,
) -> str:
"""Build title for media source identifier."""
title_parts = ["iCloud Media"]
icloud_account = None
if identifier and identifier.config_entry_id is not None:
icloud_account, title = _get_icloud_account_and_title(
self._hass, identifier
)
title_parts.append(title)
if identifier and identifier.shared_album is True:
title_parts.append("Shared Streams")
elif identifier and identifier.shared_album is False:
title_parts.append("Albums")
if icloud_account and identifier and identifier.album_id is not None:
album = await _get_photo_album(self._hass, icloud_account, identifier)
title_parts.append(album.title)
return " / ".join(title_parts)
async def async_browse_media(
self,
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
if not self._hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError(
translation_domain=DOMAIN,
translation_key="config_entry_not_loaded",
)
if not item.identifier:
return await self._async_build_icloud_accounts()
identifier = IcloudMediaSourceIdentifier.from_identifier(item.identifier)
if identifier.shared_album is None:
return await self._async_build_album_types(identifier)
icloud_account, _ = _get_icloud_account_and_title(self._hass, identifier)
if identifier.album_id is None:
return await self._async_build_albums(identifier, icloud_account)
if identifier.photo_id is None:
return await self._async_build_photos(identifier, icloud_account)
raise BrowseError(
translation_domain=DOMAIN,
translation_key="unknown_media_item",
)
async def _async_build_icloud_accounts(
self,
) -> BrowseMediaSource:
"""Handle browsing of different iCloud accounts."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(None),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(config_entry_id=entry.unique_id)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=entry.title,
can_play=False,
can_expand=True,
)
for entry in self._get_config_entries()
if entry.unique_id is not None
],
)
async def _async_build_album_types(
self,
identifier: IcloudMediaSourceIdentifier,
) -> BrowseMediaSource:
"""Handle browsing of album types (albums vs shared albums)."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=[
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=False,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Albums",
),
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=True,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title="Shared Streams",
),
],
)
async def _async_build_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of albums."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._browse_albums(identifier, icloud_account),
)
async def _async_build_photos(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> BrowseMediaSource:
"""Handle browsing of photos in an album."""
return BrowseMediaSource(
domain=DOMAIN,
identifier=None,
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
title=await self._build_title_for_identifier(identifier),
can_play=False,
can_expand=True,
children_media_class=MediaClass.DIRECTORY,
children=await self._get_photo_list(identifier, icloud_account),
)
async def _browse_albums(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Browse albums asynchronously."""
albums: AlbumContainer | None = None
if icloud_account.api is None:
raise BrowseError(
translation_domain=DOMAIN,
translation_key="account_not_initialized",
translation_placeholders={"entry": identifier.config_entry_id},
)
albums = await _get_photo_library(self._hass, icloud_account, identifier)
children: list[BrowseMediaSource] = []
if albums is not None:
for album in albums:
if isinstance(album, PhotoAlbumFolder):
continue
children.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=str(
IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=album.id,
)
),
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.ALBUM,
can_play=False,
can_expand=True,
title=album.title,
)
)
return children
async def _get_photo_list(
self,
identifier: IcloudMediaSourceIdentifier,
icloud_account: IcloudAccount,
) -> list[BrowseMediaSource]:
"""Get list of photos asynchronously."""
def _get_photo_list_sync(album: BasePhotoAlbum) -> list[BrowseMediaSource]:
"""Get list of photos synchronously."""
items: list[BrowseMediaSource] = []
for photo in album.photos:
PhotoCache.instance(icloud_account).set(photo.id, photo)
photo_id = IcloudMediaSourceIdentifier(
config_entry_id=identifier.config_entry_id,
shared_album=identifier.shared_album,
album_id=identifier.album_id,
photo_id=photo.id,
)
item = BrowseMediaSource(
domain=DOMAIN,
identifier=str(photo_id),
media_class=(
MediaClass.IMAGE
if photo.item_type == "image"
else MediaClass.VIDEO
),
media_content_type=(
MediaType.IMAGE
if photo.item_type == "image"
else MediaType.VIDEO
),
can_play=True,
can_expand=False,
title=photo.filename,
thumbnail=f"/api/icloud/media_source/serve/thumb{'' if photo.item_type == 'image' else '_image'}/{b64encode(str(photo_id).encode()).decode()}",
)
items.append(item)
return items
album: BasePhotoAlbum = await _get_photo_album(
self._hass, icloud_account, identifier
)
return await self._hass.async_add_executor_job(_get_photo_list_sync, album)
class IcloudMediaSourceView(HomeAssistantView):
"""Handle media serving via HTTP view."""
url = "/api/icloud/media_source/serve/{version}/{image_id}"
name = "api:icloud:media_source:serve"
requires_auth = True
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize iCloud media source view."""
super().__init__()
self._hass = hass
self.session = async_get_clientsession(hass)
async def get(
self,
request: web.Request,
version: str,
image_id: str,
) -> web.StreamResponse:
"""Get the image from iCloud."""
try:
identifier = IcloudMediaSourceIdentifier.from_identifier(
b64decode(image_id, validate=True).decode()
)
except (Unresolvable, binascii.Error, UnicodeDecodeError) as err:
_LOGGER.error("Error decoding iCloud media source identifier: %s", err)
raise web.HTTPBadRequest from err
try:
photo = await _get_photo_asset(self._hass, identifier)
except Unresolvable as err:
_LOGGER.error("Error resolving iCloud media source: %s", err)
raise web.HTTPNotFound from err
url = photo.versions.get(version, {}).get("url")
if url is None and version.startswith("thumb"):
# try the medium version for thumbnails if the requested version is not available, as some videos only have a medium version and no separate thumbnail version
url = photo.versions.get(version.replace("thumb", "medium"), {}).get("url")
if url is None:
raise web.HTTPNotFound
request_headers = {}
if hdrs.RANGE in request.headers:
request_headers[hdrs.RANGE] = request.headers[hdrs.RANGE]
icloud_response = await self.session.get(
url,
timeout=ClientTimeout(
connect=15, sock_connect=15, sock_read=30, total=None
),
headers=request_headers,
)
response_headers: dict[str, str] = {}
response_headers.update(CACHE_HEADERS)
response_headers[hdrs.CONTENT_DISPOSITION] = (
f'attachment;filename="{urllib.parse.quote(photo.filename, safe="")}"'
)
for header in (
hdrs.CONTENT_TYPE,
hdrs.LAST_MODIFIED,
hdrs.ACCEPT_RANGES,
hdrs.CONTENT_RANGE,
):
if header in icloud_response.headers:
response_headers[header] = icloud_response.headers[header]
response = web.StreamResponse(
status=icloud_response.status,
reason=icloud_response.reason,
headers=response_headers,
)
await response.prepare(request)
try:
async for chunk in icloud_response.content.iter_chunked(65536):
await response.write(chunk)
except TimeoutError:
_LOGGER.warning(
"Timeout while reading iCloud, writing EOF",
)
finally:
icloud_response.release()
await response.write_eof()
return response
@@ -44,6 +44,41 @@
}
}
},
"exceptions": {
"account_not_initialized": {
"message": "Account not initialized: {entry}"
},
"album_not_found": {
"message": "Album not found"
},
"album_type_not_specified": {
"message": "Album type not specified"
},
"config_entry_not_found": {
"message": "Config entry not found for account: {entry}"
},
"config_entry_not_loaded": {
"message": "Config entry not loaded"
},
"incomplete_media_source_identifier": {
"message": "Incomplete media source identifier"
},
"invalid_media_source": {
"message": "Invalid media source"
},
"invalid_view_type": {
"message": "Invalid album view type"
},
"photo_not_found": {
"message": "Photo not found"
},
"unknown_media_item": {
"message": "Unknown media item"
},
"unsupported_media_type": {
"message": "Unsupported media type"
}
},
"services": {
"display_message": {
"description": "Displays a message on an Apple device.",
+10 -4
View File
@@ -326,7 +326,9 @@ class ImageView(HomeAssistantView):
) -> ImageEntity:
"""Authenticate request and return image entity."""
if (image_entity := self.component.get_entity(entity_id)) is None:
raise web.HTTPNotFound
raise (
web.HTTPNotFound if request[KEY_AUTHENTICATED] else web.HTTPUnauthorized
)
authenticated = (
request[KEY_AUTHENTICATED]
@@ -334,11 +336,15 @@ class ImageView(HomeAssistantView):
)
if not authenticated:
# Attempt with invalid bearer token, raise unauthorized
# so ban middleware can handle it.
if hdrs.AUTHORIZATION in request.headers:
# A failed request that carried an Authorization header is a real
# Bearer auth attempt — return 401 and let the ban middleware count
# it as a wrong login.
raise web.HTTPUnauthorized
# Invalid sigAuth or image entity access token
# No Authorization header: most likely a benign signed-URL / query-
# token request whose token has expired (e.g. a browser tab left
# open that re-fetches resources later). Return 403 so it doesn't
# register as a wrong login and ban the user's own IP.
raise web.HTTPForbidden
return image_entity
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/influxdb",
"iot_class": "local_push",
"loggers": ["influxdb", "influxdb_client"],
"requirements": ["influxdb==5.3.1", "influxdb-client==1.50.0"],
"requirements": ["influxdb==5.3.2", "influxdb-client==1.50.0"],
"single_config_entry": true
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==6.0.0"]
"requirements": ["infrared-protocols==6.0.1"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
"requirements": ["pyipma==3.0.9"]
"requirements": ["pyipma==3.0.10"]
}
+2 -1
View File
@@ -31,7 +31,8 @@
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"host": "[%key:common::config_flow::data::host%]",
"protocol": "Protocol"
},
"data_description": {
"host": "Hostname or IP address of your Iskra device."
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/kaleidescape",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["pykaleidescape==1.1.5"],
"requirements": ["pykaleidescape==1.1.6"],
"ssdp": [
{
"deviceType": "schemas-upnp-org:device:Basic:1",

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