Compare commits

..

121 Commits

Author SHA1 Message Date
Heindrich Paul
0de2a16d0f Add binary sensor support and refactor NS sensor integration (#154589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 16:29:09 +01:00
David Rapan
c8c2413a09 Fix Shelly sleeping sensor with channel name (#156708)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-18 16:28:21 +01:00
Fredrik Mårtensson
291331f878 Fix is_matching in samsungtv config flow (#156594)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 16:12:21 +01:00
David
a13cdbdf3d Add new Tuya dehumidifier test fixture (#156799)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 16:07:37 +01:00
epenet
1bf713f279 Set kw_only in Tuya TypeInformation (#156804)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-18 15:58:46 +01:00
Heindrich Paul
10c8ee417b Refactor Nederlandse Spoorwegen integration (#154616)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 15:53:49 +01:00
Jamin
b23134f4f1 Reset state on error during VOIP announcement (#156384) 2025-11-18 15:36:41 +01:00
Joost Lekkerkerker
f45a6f806b Add Cosori virtual integration (#156792) 2025-11-18 13:38:32 +01:00
Ludovic BOUÉ
d3857a00d5 Rename Matter thermostat fixture (#156795) 2025-11-18 13:25:08 +01:00
Copilot
8c9b90a9f9 Fix hvv_departures to pass config_entry explicitly to DataUpdateCoordinator (#156794)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joostlek <7083755+joostlek@users.noreply.github.com>
2025-11-18 12:32:55 +01:00
Timothy
4eedc88935 Store Mobile app pending updates when enabling back an entity (#156026)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-18 12:26:13 +01:00
Abílio Costa
343ea1b82d Return target in trigger description command (#156766)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-11-18 12:16:28 +01:00
Lukas
36e13653d2 New virtual integration Vagner Pool supported by pooldose (#156678)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 11:46:46 +01:00
dependabot[bot]
80444b2165 Bump actions/checkout from 5.0.0 to 5.0.1 (#156780)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 11:43:52 +01:00
epenet
262f06dd2b Migrate Tuya light (color_temp) to use wrapper class (#156743)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-18 11:43:32 +01:00
epenet
bd87119c2e Fix blocking call in cync (#156782) 2025-11-18 11:41:54 +01:00
Andre Lengwenus
0dfa037aa8 Refactor device classes for LCN (#156791) 2025-11-18 11:39:23 +01:00
Artur Pragacz
c32a471573 Register music assistant services in async setup (#155963) 2025-11-18 11:38:58 +01:00
valexi7
97b7e51171 Add fixture for Tuya Wifi Knob Thermostat wk_t94pit6zjbask9qo (#156781)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2025-11-18 08:34:21 +01:00
David Rapan
433712b407 Add Shelly binary sensor translation (#154116)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-17 22:39:14 +02:00
Luca Angemi
5d87e0f429 Make Google sheets datetime column optional (#155861)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-11-17 19:48:17 +00:00
tronikos
acb087f1e5 Mark Google Assistant SDK as gold (#148077) 2025-11-17 17:37:41 +01:00
Andre Lengwenus
10c12623bf Switch LCN integration to local polling (#152601) 2025-11-17 15:50:26 +01:00
stegm
2fe20553b3 Add new settings option to kostal plenticore (#153162) 2025-11-17 15:45:50 +01:00
Tom Matheussen
b431bb197a Sync quality scale tracking with codebase (#156440) 2025-11-17 15:41:30 +01:00
Manu
eb9d625926 Fix return type annotations and enable strict typing in Xbox integration (#156746) 2025-11-17 14:38:02 +01:00
PaulCavill
3a69534b09 Bump pyiCloud to 2.2.0 (#156485) 2025-11-17 14:32:01 +01:00
Erik Montnemery
8f2cedcb73 Run hassfest if conditions.yaml or triggers.yaml is changed (#156738) 2025-11-17 12:26:50 +01:00
epenet
3658953ff3 Migrate Tuya light (brightness) to use wrapper class (#156735) 2025-11-17 11:58:16 +01:00
dotlambda
0be5893e37 sonos requires defusedxml (#156718) 2025-11-17 08:21:55 +01:00
Allen Porter
c87e38c4cf Add Nest config flow data_description fields to fix quality scale item (#156713) 2025-11-17 08:13:15 +01:00
Allen Porter
4874610ad6 Bump google-nest-sdm to 9.0.1 (#156707) 2025-11-16 23:13:40 +01:00
Michael
9180282fc6 Add update entity to AdGUard Home (#156682)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 22:52:38 +01:00
J. Nick Koston
118f30f32e Bump dbus to 3.0.0 (#156704) 2025-11-16 22:34:31 +01:00
Bouwe Westerdijk
bd10da126f Revisit diagnostic-category assignments for Plugwise (#156279) 2025-11-16 21:29:24 +01:00
starkillerOG
b73a7928ca Enable Reolink RTSP and ONVIF port when supported (#156700) 2025-11-16 21:24:25 +01:00
Jan Bouwhuis
3e20c2ea93 Fix missing description placeholders in MQTT subentry flow (#156684) 2025-11-16 21:21:22 +01:00
andreipoenaru
60130d3d68 Add support for encoded URLs to RESTful Command (#154957)
Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-16 21:04:18 +01:00
Denis Shulyaka
c45ede2e5d Bump anthropic to 0.73.0 (#156692) 2025-11-16 21:02:55 +01:00
J. Nick Koston
e167061f53 Bump dbus-fast to 2.46.4 (#156703) 2025-11-16 20:52:05 +01:00
Kamil Breguła
5560fb6c9e Refactor tests in GIOS (#155756)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2025-11-16 20:21:46 +01:00
J. Nick Koston
9808b6c961 Bump dbus-fast to 2.46.1 (#156695) 2025-11-16 19:34:29 +01:00
J. Nick Koston
e8cfde579e Bump dbus-fast to 2.46.0 (#156693) 2025-11-16 09:46:53 -06:00
J. Nick Koston
f695fb4d51 Bump dbus-fast to 2.45.1 (#156691) 2025-11-16 09:12:44 -06:00
Jan Bouwhuis
a0e0549d90 Fix missing temperature_delta device class translations (#156685) 2025-11-16 15:00:51 +01:00
epenet
ba034c6c8c Add alarm_state to Tuya siren alarm (#151221) 2025-11-16 12:26:33 +01:00
Åke Strandberg
008bb85c59 Mock arguments in senz tests (#156677) 2025-11-16 12:25:28 +01:00
Michael
cf1c1294d3 Bump adguardhome to 0.8.1 (#156679) 2025-11-16 12:16:17 +01:00
Åke Strandberg
11d5d314cc Fix type hints in miele tests (#156657) 2025-11-16 12:12:45 +01:00
Joost Lekkerkerker
6f0de3071a Add fixture for dual washing machine to SmartThings (#156646) 2025-11-16 11:43:25 +01:00
mettolen
87d2597292 Add diagnostics to Saunum integration (#156623) 2025-11-16 11:40:49 +01:00
Manu
437bc04fe8 Remove Live-TV support from Xbox integration (#156669) 2025-11-16 11:30:04 +01:00
Åke Strandberg
67a0d6a187 Mock arguments to ClientResponseError() in miele tests (#156676) 2025-11-16 10:03:32 +01:00
Lukas
abb52bca81 Add more sensors to Pooldose (#156002)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 23:02:27 +01:00
J. Nick Koston
d2d6889278 Bump thermopro-ble to 1.1.2 (#156652) 2025-11-15 14:10:59 -06:00
David Rapan
bdca592219 Add Shelly event translation (#156162)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-15 20:52:13 +02:00
Michael
5c0c7b9ec3 Bump adguardhome to 0.8.0 (#156651) 2025-11-15 16:26:05 +01:00
TheDK
9717599fb9 Use SensorDeviceClass.PRESSURE in Withings (#156648)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-15 16:08:34 +01:00
MarkGodwin
4d7de2f814 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-15 15:39:56 +01:00
epenet
779590ce1c Migrate Tuya climate (humidity) to use wrapper class (#156575) 2025-11-15 15:05:52 +01:00
epenet
f3a185ff9c Migrate Tuya cover to use wrapper class (#156558)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:04:54 +01:00
epenet
5a5a106984 Migrate Tuya humidifier to use wrapper class (#156572)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:03:30 +01:00
epenet
796b421d99 Migrate Tuya vacuum to use wrapper class (#156569) 2025-11-15 15:02:49 +01:00
epenet
0c03e8dbe9 Migrate Tuya light (color_mode) to use wrapper class (#156582) 2025-11-15 15:02:40 +01:00
epenet
47cf4e3ffe Ensure Tuya scale and step are integers (#156555) 2025-11-15 14:30:44 +02:00
epenet
0ea0fc151d Use parametrize in tuya climate tests (#156577) 2025-11-15 14:28:57 +02:00
Åke Strandberg
b7e5afec9f Fix typing in miele tests (#156637) 2025-11-15 12:19:34 +01:00
cdnninja
7a2bb67e82 Refactor vesync test (#156625) 2025-11-15 09:24:48 +01:00
Manu
e0612bec07 Bump pythonkuma to v0.3.2 (#156626) 2025-11-15 03:43:16 +01:00
Denis Shulyaka
a06f4b6776 Anthropic model selection from list (#156261) 2025-11-14 21:16:52 -05:00
Denis Shulyaka
275670a526 Add support for gpt-5.1 (#156612) 2025-11-14 18:39:05 -05:00
Robert Resch
d0d62526dd Bump async-upnp-client to 0.46.0 (#156622) 2025-11-14 18:30:18 -05:00
Franck Nijhof
aefdf412b0 Extract device template functions into a devices Jinja2 extension (#156619) 2025-11-15 00:23:38 +01:00
Manu
56ab6b2512 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 22:07:13 +01:00
mettolen
d1dea85cf5 Add Saunum integration (#155099)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-14 19:55:55 +01:00
wollew
84b0d39763 clean up velux test fixtures (#156554) 2025-11-14 19:53:17 +01:00
tronikos
3aff225bc3 Add Google Weather integration (#147015)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 19:46:56 +01:00
Ludovic BOUÉ
04458e01be Fix spelling of 'Auto-relock time' in Matter integration strings (#156607) 2025-11-14 19:10:57 +01:00
Thomas55555
ae51cfb8c0 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 19:10:16 +01:00
Simone Chemelli
c116a9c037 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 19:09:19 +01:00
Allen Porter
fb58758684 Add completed timestamp support in Google tasks (#156564) 2025-11-14 12:07:23 -05:00
Franck Nijhof
25fbcbc68c Extract floor template functions into a floors Jinja2 extension (#156589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:06:55 -05:00
Denis Shulyaka
a670286b45 Bump openai to 2.8.0 (#156602) 2025-11-14 12:06:21 -05:00
ElectricSteve
52ba55b17f Bump youtubeaio to 2.1.0 (#156595) 2025-11-14 16:01:00 +01:00
epenet
ff0fc98c36 Fix sfr_box entry reload (#156593) 2025-11-14 15:03:43 +01:00
David Rapan
9f78a2263d Move Shelly sensor get_entity_translation_attributes to utils (#156590)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-14 15:44:48 +02:00
Kamil Breguła
9b4696a80b Add quality_scale to mvglive manifest (#155474) 2025-11-14 13:20:10 +01:00
wollew
70fe8cae39 Fix velux scenes (naming and unique ids) (#156436) 2025-11-14 13:18:08 +01:00
wollew
95eb45ab08 cleanup registered callbacks before removing velux config entry (#156525) 2025-11-14 13:06:45 +01:00
Erwin Douna
84f8e57141 Add retry_after to UpdateFailed in update coordinator (#153550)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:19:02 +01:00
Franck Nijhof
f484b6df0d Extract label template functions into a label Jinja2 extension (#156439)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:04:31 +01:00
J. Diego Rodríguez Royo
34c1d45ee0 Ensure that Home Connect program update value event is a string when updating options (#156416)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-14 10:51:52 +01:00
epenet
09a105d9ad Migrate Tuya light (switch) to use wrapper class (#156580) 2025-11-14 10:33:58 +01:00
epenet
6bd1787d0a Improve parametrize in tuya light tests (#156581) 2025-11-14 10:19:48 +01:00
David Rapan
37040f5064 Add Shelly switch translation (#156146)
Signed-off-by: David Rapan <david@rapan.cz>
Co-authored-by: Shay Levy <levyshay1@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 11:16:04 +02:00
puddly
531397ec07 Avoid firing discovery events when flows immediately create a config entry (#155753) 2025-11-14 09:40:46 +01:00
epenet
d6cc0f81de Remove pointless super.async_added_to_hass in Tuya climate (#156573) 2025-11-14 09:31:03 +01:00
TheJulianJES
f8ef8a466a Bump ZHA to 0.0.79 (#156571) 2025-11-14 09:30:38 +01:00
Åke Strandberg
713015e26a Improve logging of failing miele action commands (#156275) 2025-11-14 09:10:24 +01:00
Åke Strandberg
f9c1e81c5e Improve error handling and add tests to senz climate (#156544) 2025-11-14 09:06:56 +01:00
Joost Lekkerkerker
0549d113e6 Bump python-open-router to 0.3.3 (#156563) 2025-11-14 08:48:47 +01:00
dependabot[bot]
0d842978ec Bump github/codeql-action from 4.31.2 to 4.31.3 (#156565) 2025-11-13 23:05:52 -08:00
Nojus
55476ef6ea Remove arbitrary forecast limit for meteo_lt (#155877) 2025-11-14 00:14:48 +01:00
starkillerOG
0e130d8fdd Bump reolink-aio to 0.16.5 (#156553) 2025-11-14 00:12:03 +01:00
slickm0nty
20bcb84956 Set suggested display precision in modbus integration (#155467) 2025-11-13 22:47:40 +00:00
jlanchares
bbb1d57081 Goodwe port502ftp support with PORT stored on config data. (#148628)
Co-authored-by: starkillerOG <starkiller.og@gmail.com>
2025-11-13 21:12:47 +01:00
hanwg
121406569b Upgrade Telegram bot quality scale to Silver (#155352) 2025-11-13 21:08:35 +01:00
Joost Lekkerkerker
4866c775ce Fix CI (#156549) 2025-11-13 21:08:08 +01:00
Willem-Jan van Rootselaar
7c5ab12270 Update bsblan to python-bsblan version 3.1.1 (#156536)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-11-13 20:44:12 +01:00
Erik Montnemery
099edfac20 Fix flux_led tests opening sockets (#156458) 2025-11-13 20:39:56 +01:00
epenet
aa31df0fd5 Migrate Tuya camera to use wrapper class (#156542) 2025-11-13 20:38:44 +01:00
Bram Kragten
13fbeb6cdb Add support for trigger and condition category icons (#156533) 2025-11-13 14:36:34 -05:00
karwosts
8d557447df Add completed timestamp to TodoItem (#156547)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-11-13 14:35:33 -05:00
Joakim Sørensen
e6e3f2455f Add discovery_service_actions configuration option (#156537) 2025-11-13 14:35:22 -05:00
epenet
c9c518ee84 Improve IntegerTypeData scaling in Tuya (#156507) 2025-11-13 20:29:07 +01:00
Magnus
214731e964 Component asuswrt: Type check is redundant for this value (#154535) 2025-11-13 20:24:24 +01:00
Jan Čermák
c4b09c9a0a Update Home Assistant base image to 2025.11.0 (#156517) 2025-11-13 20:01:22 +01:00
epenet
f5b5b2fb70 Remove unused/absent property from Tuya (#156508) 2025-11-13 19:32:08 +01:00
Manu
bb3cdd382b Add media_content_id to media player in Xbox integration (#156519) 2025-11-13 19:25:43 +01:00
starkillerOG
8d09b5c273 Relax Reolink update interval and timeout for big installs (#156509) 2025-11-13 19:25:03 +01:00
epenet
d92fa7fa72 Move more logic from entity to wrapper in Tuya alarm (#156450) 2025-11-13 18:37:47 +01:00
387 changed files with 18698 additions and 7778 deletions

View File

@@ -27,7 +27,7 @@ jobs:
publish: ${{ steps.version.outputs.publish }}
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0
@@ -94,7 +94,7 @@ jobs:
- arch: i386
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
@@ -227,7 +227,7 @@ jobs:
- green
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Set build additional args
run: |
@@ -265,7 +265,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Initialize git
uses: home-assistant/actions/helpers/git-init@master
@@ -309,7 +309,7 @@ jobs:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
@@ -418,7 +418,7 @@ jobs:
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
@@ -463,7 +463,7 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Login to GitHub Container Registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0

View File

@@ -99,7 +99,7 @@ jobs:
steps:
- &checkout
name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Generate partial Python venv restore key
id: generate_python_cache_key
run: |

View File

@@ -21,14 +21,14 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
category: "/language:python"

View File

@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- &checkout
name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python

View File

@@ -87,7 +87,7 @@ repos:
pass_filenames: false
language: script
types: [text]
files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/.+/(quality_scale)\.yaml|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|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|requirements.+\.txt)$
- id: hassfest-metadata
name: hassfest-metadata
entry: script/run-in-env.sh python3 -m script.hassfest -p metadata,docker

View File

@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.*
homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*
@@ -578,6 +579,7 @@ homeassistant.components.wiz.*
homeassistant.components.wled.*
homeassistant.components.workday.*
homeassistant.components.worldclock.*
homeassistant.components.xbox.*
homeassistant.components.xiaomi_ble.*
homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.*

6
CODEOWNERS generated
View File

@@ -516,8 +516,6 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -609,6 +607,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
/homeassistant/components/google_weather/ @tronikos
/tests/components/google_weather/ @tronikos
/homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen
@@ -1376,6 +1376,8 @@ build.json @home-assistant/supervisor
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/saunum/ @mettolen
/tests/components/saunum/ @mettolen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core

View File

@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.11.0
cosign:
base_identity: https://github.com/home-assistant/docker/.*
identity: https://github.com/home-assistant/core/.*

View File

@@ -15,6 +15,7 @@
"google_tasks",
"google_translate",
"google_travel_time",
"google_weather",
"google_wifi",
"google",
"nest",

View File

@@ -45,7 +45,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
type AdGuardConfigEntry = ConfigEntry[AdGuardData]

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.7.0"]
"requirements": ["adguardhome==0.8.1"]
}

View File

@@ -0,0 +1,71 @@
"""AdGuard Home Update platform."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from adguardhome import AdGuardHomeError
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
from .entity import AdGuardHomeEntity
SCAN_INTERVAL = timedelta(seconds=300)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: AdGuardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdGuard Home update entity based on a config entry."""
data = entry.runtime_data
if (await data.client.update.update_available()).disabled:
return
async_add_entities([AdGuardHomeUpdate(data, entry)], True)
class AdGuardHomeUpdate(AdGuardHomeEntity, UpdateEntity):
"""Defines an AdGuard Home update."""
_attr_supported_features = UpdateEntityFeature.INSTALL
_attr_name = None
def __init__(
self,
data: AdGuardData,
entry: AdGuardConfigEntry,
) -> None:
"""Initialize AdGuard Home update."""
super().__init__(data, entry)
self._attr_unique_id = "_".join(
[DOMAIN, self.adguard.host, str(self.adguard.port), "update"]
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
value = await self.adguard.update.update_available()
self._attr_installed_version = self.data.version
self._attr_latest_version = value.new_version
self._attr_release_summary = value.announcement
self._attr_release_url = value.announcement_url
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install latest update."""
try:
await self.adguard.update.begin_update()
except AdGuardHomeError as err:
raise HomeAssistantError(f"Failed to install update: {err}") from err
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)

View File

@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
)
self.api = AmazonEchoApi(
session,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from functools import partial
import json
import logging
import re
from typing import Any
import anthropic
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_CHAT_MODEL,
default=RECOMMENDED_CHAT_MODEL,
): str,
): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
),
vol.Optional(
CONF_MAX_TOKENS,
default=RECOMMENDED_MAX_TOKENS,
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True,
)
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}

View File

@@ -392,7 +392,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
input={},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -459,7 +459,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input="",
input={},
)
current_tool_args = ""
elif isinstance(response.content_block, WebSearchToolResultBlock):

View File

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

View File

@@ -111,8 +111,6 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
if isinstance(data, dict):
return dict(zip(keys, list(data.values()), strict=False))
if not isinstance(data, (list, tuple)):
raise UpdateFailed("Received invalid data type")
return dict(zip(keys, data, strict=False))
return _wrapper

View File

@@ -20,7 +20,7 @@
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==2.45.0",
"dbus-fast==3.0.0",
"habluetooth==5.7.0"
]
}

View File

@@ -74,8 +74,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
super().__init__(data.fast_coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
self._attr_min_temp = data.static.min_temp.value
self._attr_max_temp = data.static.max_temp.value
# Set temperature range if available, otherwise use Home Assistant defaults
if data.static.min_temp is not None and data.static.min_temp.value is not None:
self._attr_min_temp = data.static.min_temp.value
if data.static.max_temp is not None and data.static.max_temp.value is not None:
self._attr_max_temp = data.static.max_temp.value
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
@property

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.0"],
"requirements": ["python-bsblan==3.1.1"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -55,6 +55,7 @@ from .const import (
CONF_ALIASES,
CONF_API_SERVER,
CONF_COGNITO_CLIENT_ID,
CONF_DISCOVERY_SERVICE_ACTIONS,
CONF_ENTITY_CONFIG,
CONF_FILTER,
CONF_GOOGLE_ACTIONS,
@@ -139,6 +140,7 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_MODE): vol.In([MODE_DEV]),
vol.Required(CONF_API_SERVER): str,
vol.Optional(CONF_DISCOVERY_SERVICE_ACTIONS): {str: cv.url},
}
),
_BASE_CONFIG_SCHEMA.extend(

View File

@@ -79,6 +79,7 @@ CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"
CONF_RELAYER_SERVER = "relayer_server"
CONF_REMOTESTATE_SERVER = "remotestate_server"
CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server"

View File

@@ -0,0 +1 @@
"""Virtual integration: Cosori."""

View File

@@ -0,0 +1,6 @@
{
"domain": "cosori",
"name": "Cosori",
"integration_type": "virtual",
"supported_by": "vesync"
}

View File

@@ -9,6 +9,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.ssl import get_default_context
from .const import (
CONF_AUTHORIZE_STRING,
@@ -31,9 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool
expires_at=entry.data[CONF_EXPIRES_AT],
)
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
ssl_context = get_default_context()
try:
cync = await Cync.create(cync_auth)
cync = await Cync.create(
auth=cync_auth,
ssl_context=ssl_context,
)
except AuthFailedError as ex:
raise ConfigEntryAuthFailed("User token invalid") from ex
except CyncError as ex:

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.45.0"],
"requirements": ["async-upnp-client==0.46.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -1,31 +0,0 @@
"""The Fluss+ integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from .coordinator import FlussDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.BUTTON]
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,
) -> bool:
"""Set up Fluss+ from a config entry."""
coordinator = FlussDataUpdateCoordinator(hass, entry, entry.data[CONF_API_KEY])
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FlussConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,38 +0,0 @@
"""Support for Fluss Devices."""
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FlussApiClientError, FlussDataUpdateCoordinator
from .entity import FlussEntity
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
async def async_setup_entry(
hass: HomeAssistant,
entry: FlussConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fluss Devices, filtering out any invalid payloads."""
coordinator = entry.runtime_data
devices = coordinator.data
async_add_entities(
FlussButton(coordinator, device_id, device)
for device_id, device in devices.items()
)
class FlussButton(FlussEntity, ButtonEntity):
"""Representation of a Fluss button device."""
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.coordinator.api.async_trigger_device(self.device_id)
except FlussApiClientError as err:
raise HomeAssistantError(f"Failed to trigger device: {err}") from err

View File

@@ -1,54 +0,0 @@
"""Config flow for Fluss+ integration."""
from __future__ import annotations
from typing import Any
from fluss_api import (
FlussApiClient,
FlussApiClientAuthenticationError,
FlussApiClientCommunicationError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
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 async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
self._async_abort_entries_match({CONF_API_KEY: api_key})
try:
FlussApiClient(
user_input[CONF_API_KEY], session=async_get_clientsession(self.hass)
)
except FlussApiClientCommunicationError:
errors["base"] = "cannot_connect"
except FlussApiClientAuthenticationError:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception occurred")
errors["base"] = "unknown"
if not errors:
return self.async_create_entry(
title="My Fluss+ Devices", data=user_input
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -1,9 +0,0 @@
"""Constants for the Fluss+ integration."""
from datetime import timedelta
import logging
DOMAIN = "fluss"
LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = 60 # seconds
UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL)

View File

@@ -1,50 +0,0 @@
"""DataUpdateCoordinator for Fluss+ integration."""
from __future__ import annotations
from typing import Any
from fluss_api import (
FlussApiClient,
FlussApiClientAuthenticationError,
FlussApiClientError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import slugify
from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA
type FlussConfigEntry = ConfigEntry[FlussDataUpdateCoordinator]
class FlussDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages fetching Fluss device data on a schedule."""
def __init__(
self, hass: HomeAssistant, config_entry: FlussConfigEntry, api_key: str
) -> None:
"""Initialize the coordinator."""
self.api = FlussApiClient(api_key, session=async_get_clientsession(hass))
super().__init__(
hass,
LOGGER,
name=f"Fluss+ ({slugify(api_key[:8])})",
config_entry=config_entry,
update_interval=UPDATE_INTERVAL_TIMEDELTA,
)
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch data from the Fluss API and return as a dictionary keyed by deviceId."""
try:
devices = await self.api.async_get_devices()
except FlussApiClientAuthenticationError as err:
raise ConfigEntryAuthFailed(f"Authentication failed: {err}") from err
except FlussApiClientError as err:
raise UpdateFailed(f"Error fetching Fluss devices: {err}") from err
return {device["deviceId"]: device for device in devices.get("devices", [])}

View File

@@ -1,36 +0,0 @@
"""Base entities for the Fluss+ integration."""
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import FlussDataUpdateCoordinator
class FlussEntity(CoordinatorEntity[FlussDataUpdateCoordinator]):
"""Base class for Fluss entities."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
coordinator: FlussDataUpdateCoordinator,
device_id: str,
device: dict,
) -> None:
"""Initialize the entity with a device ID and device data."""
super().__init__(coordinator)
self.device_id = device_id
self._device = device
self._attr_unique_id = f"{device_id}"
self._attr_device_info = DeviceInfo(
identifiers={("fluss", device_id)},
name=device.get("deviceName"),
manufacturer="Fluss",
model="Fluss+ Device",
)
@property
def device(self) -> dict:
"""Return the stored device data."""
return self._device

View File

@@ -1,11 +0,0 @@
{
"domain": "fluss",
"name": "Fluss+",
"codeowners": ["@fluss"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fluss",
"iot_class": "cloud_polling",
"loggers": ["fluss-api"],
"quality_scale": "bronze",
"requirements": ["fluss-api==0.1.9.17"]
}

View File

@@ -1,20 +0,0 @@
{
"config": {
"step": {
"user": {
"description": "Your Fluss API key, available in the profile page of the Fluss+ app",
"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."
}
}
},
"error": {
"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%]"
}
}
}

View File

@@ -777,7 +777,9 @@ class ManifestJSONView(HomeAssistantView):
@websocket_api.websocket_command(
{
"type": "frontend/get_icons",
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
vol.Required("category"): vol.In(
{"entity", "entity_component", "services", "triggers", "conditions"}
),
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
}
)

View File

@@ -1,13 +1,14 @@
"""The Goodwe inverter component."""
from goodwe import InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_UDP_PORT
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo
from .config_flow import GoodweFlowHandler
from .const import CONF_MODEL_FAMILY, DOMAIN, PLATFORMS
from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoordinator
@@ -15,28 +16,22 @@ from .coordinator import GoodweConfigEntry, GoodweRuntimeData, GoodweUpdateCoord
async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bool:
"""Set up the Goodwe components from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data.get(CONF_PORT, GOODWE_UDP_PORT)
model_family = entry.data[CONF_MODEL_FAMILY]
# Connect to Goodwe inverter
try:
inverter = await connect(
host=host,
port=GOODWE_UDP_PORT,
port=port,
family=model_family,
retries=10,
)
except InverterError as err_udp:
# First try with UDP failed, trying with the TCP port
except InverterError as err:
try:
inverter = await connect(
host=host,
port=GOODWE_TCP_PORT,
family=model_family,
retries=10,
)
inverter = await async_check_port(hass, entry, host)
except InverterError:
# Both ports are unavailable
raise ConfigEntryNotReady from err_udp
raise ConfigEntryNotReady from err
device_info = DeviceInfo(
configuration_url="https://www.semsportal.com",
@@ -66,6 +61,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo
return True
async def async_check_port(
hass: HomeAssistant, entry: GoodweConfigEntry, host: str
) -> Inverter:
"""Check the communication port of the inverter, it may have changed after a firmware update."""
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(host=host)
family = type(inverter).__name__
hass.config_entries.async_update_entry(
entry,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: family,
},
)
return inverter
async def async_unload_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool:
@@ -76,3 +88,31 @@ async def async_unload_entry(
async def update_listener(hass: HomeAssistant, config_entry: GoodweConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(config_entry.entry_id)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: GoodweConfigEntry
) -> bool:
"""Migrate old config entries."""
if config_entry.version > 2:
# This means the user has downgraded from a future version
return False
if config_entry.version == 1:
# Update from version 1 to version 2 adding the PROTOCOL to the config entry
host = config_entry.data[CONF_HOST]
try:
inverter, port = await GoodweFlowHandler.async_detect_inverter_port(
host=host
)
except InverterError as err:
raise ConfigEntryNotReady from err
new_data = {
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__,
}
hass.config_entries.async_update_entry(config_entry, data=new_data, version=2)
return True

View File

@@ -5,12 +5,12 @@ from __future__ import annotations
import logging
from typing import Any
from goodwe import InverterError, connect
from goodwe import Inverter, InverterError, connect
from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PORT
from .const import CONF_MODEL_FAMILY, DEFAULT_NAME, DOMAIN
@@ -26,9 +26,15 @@ _LOGGER = logging.getLogger(__name__)
class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Goodwe config flow."""
VERSION = 1
MINOR_VERSION = 2
async def _handle_successful_connection(self, inverter, host):
async def async_handle_successful_connection(
self,
inverter: Inverter,
host: str,
port: int,
) -> ConfigFlowResult:
"""Handle a successful connection storing it's values on the entry data."""
await self.async_set_unique_id(inverter.serial_number)
self._abort_if_unique_id_configured()
@@ -36,6 +42,7 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
title=DEFAULT_NAME,
data={
CONF_HOST: host,
CONF_PORT: port,
CONF_MODEL_FAMILY: type(inverter).__name__,
},
)
@@ -48,19 +55,26 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
host = user_input[CONF_HOST]
try:
inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10)
inverter, port = await self.async_detect_inverter_port(host=host)
except InverterError:
try:
inverter = await connect(
host=host, port=GOODWE_TCP_PORT, retries=10
)
except InverterError:
errors[CONF_HOST] = "connection_error"
else:
return await self._handle_successful_connection(inverter, host)
errors[CONF_HOST] = "connection_error"
else:
return await self._handle_successful_connection(inverter, host)
return await self.async_handle_successful_connection(
inverter, host, port
)
return self.async_show_form(
step_id="user", data_schema=CONFIG_SCHEMA, errors=errors
)
@staticmethod
async def async_detect_inverter_port(
host: str,
) -> tuple[Inverter, int]:
"""Detects the port of the Inverter."""
port = GOODWE_UDP_PORT
try:
inverter = await connect(host=host, port=port, retries=10)
except InverterError:
port = GOODWE_TCP_PORT
inverter = await connect(host=host, port=port, retries=10)
return inverter, port

View File

@@ -7,6 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "gold",
"requirements": ["gassist-text==0.0.14"],
"single_config_entry": true
}

View File

@@ -0,0 +1,98 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: No polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No entities.
entity-unique-id:
status: exempt
comment: No entities.
has-entity-name:
status: exempt
comment: No entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: No entities.
integration-owner: done
log-when-unavailable:
status: exempt
comment: No entities.
parallel-updates:
status: exempt
comment: No entities to update.
reauthentication-flow: done
test-coverage: done
# Gold
devices:
status: exempt
comment: This integration acts as a service and does not represent physical devices.
diagnostics: done
discovery-update-info:
status: exempt
comment: No discovery.
discovery:
status: exempt
comment: This is a cloud service integration that cannot be discovered locally.
docs-data-update:
status: exempt
comment: No entities to update.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: No devices.
entity-category:
status: exempt
comment: No entities.
entity-device-class:
status: exempt
comment: No entities.
entity-disabled-by-default:
status: exempt
comment: No entities.
entity-translations:
status: exempt
comment: No entities.
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: exempt
comment: No repairs.
stale-devices:
status: exempt
comment: No devices.
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: The underlying library uses gRPC, not aiohttp/httpx, for communication.
strict-typing: done

View File

@@ -56,6 +56,9 @@
"init": {
"data": {
"language_code": "Language code"
},
"data_description": {
"language_code": "Language for the Google Assistant SDK requests and responses."
}
}
}

View File

@@ -31,6 +31,7 @@ from .const import DOMAIN
if TYPE_CHECKING:
from . import GoogleSheetsConfigEntry
ADD_CREATED_COLUMN = "add_created_column"
DATA = "data"
DATA_CONFIG_ENTRY = "config_entry"
ROWS = "rows"
@@ -43,6 +44,7 @@ SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
vol.Optional(WORKSHEET): cv.string,
vol.Optional(ADD_CREATED_COLUMN, default=True): cv.boolean,
vol.Required(DATA): vol.Any(cv.ensure_list, [dict]),
},
)
@@ -69,10 +71,11 @@ def _append_to_sheet(call: ServiceCall, entry: GoogleSheetsConfigEntry) -> None:
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
add_created_column = call.data[ADD_CREATED_COLUMN]
now = str(datetime.now())
rows = []
for d in call.data[DATA]:
row_data = {"created": now} | d
row_data = ({"created": now} | d) if add_created_column else d
row = [row_data.get(column, "") for column in columns]
for key, value in row_data.items():
if key not in columns:

View File

@@ -9,6 +9,11 @@ append_sheet:
example: "Sheet1"
selector:
text:
add_created_column:
required: false
default: true
selector:
boolean:
data:
required: true
example: '{"hello": world, "cool": True, "count": 5}'

View File

@@ -45,6 +45,10 @@
"append_sheet": {
"description": "Appends data to a worksheet in Google Sheets.",
"fields": {
"add_created_column": {
"description": "Add a \"created\" column with the current date-time to the appended data.",
"name": "Add created column"
},
"config_entry": {
"description": "The sheet to add data to.",
"name": "Sheet"

View File

@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
# Due dates are returned always in UTC so we only need to
# parse the date portion which will be interpreted as a a local date.
due = datetime.fromisoformat(due_str).date()
completed: datetime | None = None
if (completed_str := item.get("completed")) is not None:
completed = datetime.fromisoformat(completed_str)
return TodoItem(
summary=item["title"],
uid=item["id"],
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
TodoItemStatus.NEEDS_ACTION,
),
due=due,
completed=completed,
description=item.get("notes"),
)

View File

@@ -0,0 +1,84 @@
"""The Google Weather integration."""
from __future__ import annotations
import asyncio
from google_weather_api import GoogleWeatherApi
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_REFERRER
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherRuntimeData,
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Set up Google Weather from a config entry."""
api = GoogleWeatherApi(
session=async_get_clientsession(hass),
api_key=entry.data[CONF_API_KEY],
referrer=entry.data.get(CONF_REFERRER),
language_code=hass.config.language,
)
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
for subentry in entry.subentries.values():
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
hass, entry, subentry, api
),
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
hass, entry, subentry, api
),
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
hass, entry, subentry, api
),
)
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
tasks = [
coro
for subentry_runtime_data in subentries_runtime_data.values()
for coro in (
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
]
await asyncio.gather(*tasks)
entry.runtime_data = GoogleWeatherRuntimeData(
api=api,
subentries_runtime_data=subentries_runtime_data,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,198 @@
"""Config flow for the Google Weather integration."""
from __future__ import annotations
import logging
from typing import Any
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(SECTION_API_KEY_OPTIONS): section(
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
),
}
)
async def _validate_input(
user_input: dict[str, Any],
api: GoogleWeatherApi,
errors: dict[str, str],
description_placeholders: dict[str, str],
) -> bool:
try:
await api.async_get_current_conditions(
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
errors["base"] = "cannot_connect"
description_placeholders["error_message"] = str(err)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return True
return False
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
"""Return the schema for a location with default values from the hass config."""
return vol.Schema(
{
vol.Required(CONF_NAME, default=hass.config.location_name): str,
vol.Required(
CONF_LOCATION,
default={
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
},
): LocationSelector(LocationSelectorConfig(radius=False)),
}
)
def _is_location_already_configured(
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
) -> bool:
"""Check if the location is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
# A more accurate way is to use the haversine formula, but for simplicity
# we use a simple distance check. The epsilon value is small anyway.
# This is mostly to capture cases where the user has slightly moved the location pin.
if (
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
<= epsilon
):
return True
return False
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Weather."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api = GoogleWeatherApi(
session=async_get_clientsession(self.hass),
api_key=api_key,
referrer=referrer,
language_code=self.hass.config.language,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title="Google Weather",
data={
CONF_API_KEY: api_key,
CONF_REFERRER: referrer,
},
subentries=[
{
"subentry_type": "location",
"data": user_input[CONF_LOCATION],
"title": user_input[CONF_NAME],
"unique_id": None,
},
],
)
else:
user_input = {}
schema = STEP_USER_DATA_SCHEMA.schema.copy()
schema.update(_get_location_schema(self.hass).schema)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"location": LocationSubentryFlowHandler}
class LocationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for location."""
async def async_step_location(
self,
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api: GoogleWeatherApi = self._get_entry().runtime_data.api
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
else:
user_input = {}
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
async_step_user = async_step_location

View File

@@ -0,0 +1,8 @@
"""Constants for the Google Weather integration."""
from typing import Final
DOMAIN = "google_weather"
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
CONF_REFERRER: Final = "referrer"

View File

@@ -0,0 +1,169 @@
"""The Google Weather coordinator."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TypeVar
from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
GoogleWeatherApi,
GoogleWeatherApiError,
HourlyForecastResponse,
)
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
"T",
bound=(
CurrentConditionsResponse
| DailyForecastResponse
| HourlyForecastResponse
| None
),
)
@dataclass
class GoogleWeatherSubEntryRuntimeData:
"""Runtime data for a Google Weather sub-entry."""
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
@dataclass
class GoogleWeatherRuntimeData:
"""Runtime data for the Google Weather integration."""
api: GoogleWeatherApi
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
"""Base class for Google Weather coordinators."""
config_entry: GoogleWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
data_type_name: str,
update_interval: timedelta,
api_method: Callable[..., Awaitable[T]],
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
update_interval=update_interval,
)
self.subentry = subentry
self._data_type_name = data_type_name
self._api_method = api_method
async def _async_update_data(self) -> T:
"""Fetch data from API and handle errors."""
try:
return await self._api_method(
self.subentry.data[CONF_LATITUDE],
self.subentry.data[CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
_LOGGER.error(
"Error fetching %s for %s: %s",
self._data_type_name,
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
class GoogleWeatherCurrentConditionsCoordinator(
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
):
"""Handle fetching current weather conditions."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"current weather conditions",
timedelta(minutes=15),
api.async_get_current_conditions,
)
class GoogleWeatherDailyForecastCoordinator(
GoogleWeatherBaseCoordinator[DailyForecastResponse]
):
"""Handle fetching daily weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"daily weather forecast",
timedelta(hours=1),
api.async_get_daily_forecast,
)
class GoogleWeatherHourlyForecastCoordinator(
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
):
"""Handle fetching hourly weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"hourly weather forecast",
timedelta(hours=1),
api.async_get_hourly_forecast,
)

View File

@@ -0,0 +1,28 @@
"""Base entity for Google Weather."""
from __future__ import annotations
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import GoogleWeatherConfigEntry
class GoogleWeatherBaseEntity(Entity):
"""Base entity for all Google Weather entities."""
_attr_has_entity_name = True
def __init__(
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "google_weather",
"name": "Google Weather",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_weather",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"requirements": ["python-google-weather-api==0.0.4"]
}

View File

@@ -2,68 +2,81 @@ rules:
# Bronze
action-setup:
status: exempt
comment: |
No actions present
comment: No actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-actions:
status: exempt
comment: No actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-event-setup:
status: exempt
comment: No events subscribed.
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
action-exceptions:
status: exempt
comment: No actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-configuration-parameters:
status: exempt
comment: No configuration options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
test-coverage: done
# Gold
entity-translations: done
entity-device-class: done
devices: done
entity-category: done
entity-disabled-by-default:
status: exempt
comment: |
Not needed
discovery: todo
stale-devices: todo
diagnostics: todo
exception-translations: todo
icon-translations:
discovery-update-info:
status: exempt
comment: |
No icons used
comment: No discovery.
discovery:
status: exempt
comment: No discovery.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: No physical devices.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: N/A
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
dynamic-devices: todo
discovery-update-info: todo
repair-issues:
status: exempt
comment: |
No issues to repair
docs-use-cases: done
docs-supported-devices: todo
docs-supported-functions: done
docs-data-update: todo
docs-known-limitations: done
docs-troubleshooting: todo
docs-examples: todo
comment: No repairs.
stale-devices:
status: exempt
comment: N/A
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
strict-typing: done

View File

@@ -0,0 +1,65 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
"location": "Location coordinates",
"name": "Location name"
},
"description": "Get your API key from [here]({api_key_url}).",
"sections": {
"api_key_options": {
"data": {
"referrer": "HTTP referrer"
},
"data_description": {
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
},
"name": "Optional API key options"
}
}
}
}
},
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled."
},
"entry_type": "Location",
"error": {
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "Add location"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
}
}
}
}
}
}

View File

@@ -0,0 +1,366 @@
"""Weather entity."""
from __future__ import annotations
from google_weather_api import (
DailyForecastResponse,
HourlyForecastResponse,
WeatherCondition,
)
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_IS_DAYTIME,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_DEW_POINT,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
CoordinatorWeatherEntity,
Forecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
}
def _get_condition(
api_condition: WeatherCondition.Type, is_daytime: bool
) -> str | None:
"""Map Google Weather condition to Home Assistant condition."""
cond = _CONDITION_MAP[api_condition]
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
return ATTR_CONDITION_CLEAR_NIGHT
return cond
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
for subentry in entry.subentries.values():
async_add_entities(
[GoogleWeatherEntity(entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherEntity(
CoordinatorWeatherEntity[
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherDailyForecastCoordinator,
],
GoogleWeatherBaseEntity,
):
"""Representation of a Google Weather entity."""
_attr_attribution = "Data from Google Weather"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.MBAR
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
| WeatherEntityFeature.FORECAST_TWICE_DAILY
)
def __init__(
self,
entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the weather entity."""
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
super().__init__(
observation_coordinator=subentry_runtime_data.coordinator_observation,
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
)
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
@property
def condition(self) -> str | None:
"""Return the current condition."""
return _get_condition(
self.coordinator.data.weather_condition.type,
self.coordinator.data.is_daytime,
)
@property
def native_temperature(self) -> float:
"""Return the temperature."""
return self.coordinator.data.temperature.degrees
@property
def native_apparent_temperature(self) -> float:
"""Return the apparent temperature."""
return self.coordinator.data.feels_like_temperature.degrees
@property
def native_dew_point(self) -> float:
"""Return the dew point."""
return self.coordinator.data.dew_point.degrees
@property
def humidity(self) -> int:
"""Return the humidity."""
return self.coordinator.data.relative_humidity
@property
def uv_index(self) -> float:
"""Return the UV index."""
return float(self.coordinator.data.uv_index)
@property
def native_pressure(self) -> float:
"""Return the pressure."""
return self.coordinator.data.air_pressure.mean_sea_level_millibars
@property
def native_wind_gust_speed(self) -> float:
"""Return the wind gust speed."""
return self.coordinator.data.wind.gust.value
@property
def native_wind_speed(self) -> float:
"""Return the wind speed."""
return self.coordinator.data.wind.speed.value
@property
def wind_bearing(self) -> int:
"""Return the wind bearing."""
return self.coordinator.data.wind.direction.degrees
@property
def native_visibility(self) -> float:
"""Return the visibility."""
return self.coordinator.data.visibility.distance
@property
def cloud_coverage(self) -> float:
"""Return the Cloud coverage in %."""
return float(self.coordinator.data.cloud_cover)
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
coordinator = self.forecast_coordinators["daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.daytime_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
item.daytime_forecast.precipitation.probability.percent,
item.nighttime_forecast.precipitation.probability.percent,
),
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: (
item.daytime_forecast.precipitation.qpf.quantity
+ item.nighttime_forecast.precipitation.qpf.quantity
),
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
item.feels_like_max_temperature.degrees
),
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
item.daytime_forecast.wind.gust.value,
item.nighttime_forecast.wind.gust.value,
),
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
item.daytime_forecast.wind.speed.value,
item.nighttime_forecast.wind.speed.value,
),
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
}
for item in daily_data.forecast_days
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
coordinator = self.forecast_coordinators["hourly"]
assert coordinator
hourly_data = coordinator.data
assert isinstance(hourly_data, HourlyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.weather_condition.type, item.is_daytime
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
ATTR_FORECAST_UV_INDEX: item.uv_index,
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
}
for item in hourly_data.forecast_hours
]
@callback
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = self.forecast_coordinators["twice_daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
forecasts: list[Forecast] = []
for item in daily_data.forecast_days:
# Process daytime forecast
day_forecast = item.daytime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
day_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: True,
}
)
# Process nighttime forecast
night_forecast = item.nighttime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
night_forecast.weather_condition.type, is_daytime=False
),
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: False,
}
)
return forecasts

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
) and isinstance(event_value, str):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(cast(str, event.value)),
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)

View File

@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
TooManyRequestsError,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
state = STATE_UNAVAILABLE if not available else self.state
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
_LOGGER.debug("Updated %s", self)
@property
def bsh_key(self) -> str:
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
as event updates should take precedence over the coordinator
refresh.
"""
return self._attr_available
return self.appliance.info.connected and self._attr_available
class HomeConnectOptionEntity(HomeConnectEntity):

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity."""
super().__init__(coordinator)
self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2)
model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
"Husqvarna "
).removeprefix("HUSQVARNA ")
parts = model_witout_manufacturer.split(maxsplit=1)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0],
model=parts[1],
model_id=parts[2],
manufacturer="Husqvarna",
model=parts[0].capitalize().removesuffix("®"),
model_id=parts[1],
name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden",

View File

@@ -112,6 +112,7 @@ async def async_setup_entry(
update_method=async_update_data,
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(hours=1),
config_entry=entry,
)
# Fetch initial data so we have data when entities subscribe

View File

@@ -12,6 +12,7 @@ from pyicloud.exceptions import (
PyiCloudFailedLoginException,
PyiCloudNoDevicesException,
PyiCloudServiceNotActivatedException,
PyiCloudServiceUnavailable,
)
from pyicloud.services.findmyiphone import AppleDevice
@@ -130,15 +131,21 @@ class IcloudAccount:
except (
PyiCloudServiceNotActivatedException,
PyiCloudNoDevicesException,
PyiCloudServiceUnavailable,
) as err:
_LOGGER.error("No iCloud device found")
raise ConfigEntryNotReady from err
self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}"
if user_info is None:
raise ConfigEntryNotReady("No user info found in iCloud devices response")
self._owner_fullname = (
f"{user_info.get('firstName')} {user_info.get('lastName')}"
)
self._family_members_fullname = {}
if user_info.get("membersInfo") is not None:
for prs_id, member in user_info["membersInfo"].items():
for prs_id, member in user_info.get("membersInfo").items():
self._family_members_fullname[prs_id] = (
f"{member['firstName']} {member['lastName']}"
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/icloud",
"iot_class": "cloud_polling",
"loggers": ["keyrings.alt", "pyicloud"],
"requirements": ["pyicloud==2.1.0"]
"requirements": ["pyicloud==2.2.0"]
}

View File

@@ -237,14 +237,23 @@ class SettingDataUpdateCoordinator(
"""Implementation of PlenticoreUpdateCoordinator for settings data."""
async def _async_update_data(self) -> Mapping[str, Mapping[str, str]]:
client = self._plenticore.client
if not self._fetch or client is None:
if (client := self._plenticore.client) is None:
return {}
_LOGGER.debug("Fetching %s for %s", self.name, self._fetch)
fetch = defaultdict(set)
return await client.get_setting_values(self._fetch)
for module_id, data_ids in self._fetch.items():
fetch[module_id].update(data_ids)
for module_id, data_id in self.async_contexts():
fetch[module_id].add(data_id)
if not fetch:
return {}
_LOGGER.debug("Fetching %s for %s", self.name, fetch)
return await client.get_setting_values(fetch)
class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):

View File

@@ -34,6 +34,29 @@ async def async_get_config_entry_diagnostics(
},
}
# Add important information how the inverter is configured
string_count_setting = await plenticore.client.get_setting_values(
"devices:local", "Properties:StringCnt"
)
try:
string_count = int(
string_count_setting["devices:local"]["Properties:StringCnt"]
)
except ValueError:
string_count = 0
configuration_settings = await plenticore.client.get_setting_values(
"devices:local",
(
"Properties:StringCnt",
*(f"Properties:String{idx}Features" for idx in range(string_count)),
),
)
data["configuration"] = {
**configuration_settings,
}
device_info = {**plenticore.device_info}
device_info[ATTR_IDENTIFIERS] = REDACTED # contains serial number
data["device"] = device_info

View File

@@ -5,12 +5,13 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from typing import Any, Final
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -66,7 +67,7 @@ async def async_setup_entry(
"""Add kostal plenticore Switch."""
plenticore = entry.runtime_data
entities = []
entities: list[Entity] = []
available_settings_data = await plenticore.client.get_settings()
settings_data_update_coordinator = SettingDataUpdateCoordinator(
@@ -103,6 +104,57 @@ async def async_setup_entry(
)
)
# add shadow management switches for strings which support it
string_count_setting = await plenticore.client.get_setting_values(
"devices:local", "Properties:StringCnt"
)
try:
string_count = int(
string_count_setting["devices:local"]["Properties:StringCnt"]
)
except ValueError:
string_count = 0
dc_strings = tuple(range(string_count))
dc_string_feature_ids = tuple(
PlenticoreShadowMgmtSwitch.DC_STRING_FEATURE_DATA_ID % dc_string
for dc_string in dc_strings
)
dc_string_features = await plenticore.client.get_setting_values(
PlenticoreShadowMgmtSwitch.MODULE_ID,
dc_string_feature_ids,
)
for dc_string, dc_string_feature_id in zip(
dc_strings, dc_string_feature_ids, strict=True
):
try:
dc_string_feature = int(
dc_string_features[PlenticoreShadowMgmtSwitch.MODULE_ID][
dc_string_feature_id
]
)
except ValueError:
dc_string_feature = 0
if dc_string_feature == PlenticoreShadowMgmtSwitch.SHADOW_MANAGEMENT_SUPPORT:
entities.append(
PlenticoreShadowMgmtSwitch(
settings_data_update_coordinator,
dc_string,
entry.entry_id,
entry.title,
plenticore.device_info,
)
)
else:
_LOGGER.debug(
"Skipping shadow management for DC string %d, not supported (Feature: %d)",
dc_string + 1,
dc_string_feature,
)
async_add_entities(entities)
@@ -136,7 +188,6 @@ class PlenticoreDataSwitch(
self.off_value = description.off_value
self.off_label = description.off_label
self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}"
self._attr_device_info = device_info
@property
@@ -189,3 +240,98 @@ class PlenticoreDataSwitch(
f"{self.platform_name} {self._name} {self.off_label}"
)
return bool(self.coordinator.data[self.module_id][self.data_id] == self._is_on)
class PlenticoreShadowMgmtSwitch(
CoordinatorEntity[SettingDataUpdateCoordinator], SwitchEntity
):
"""Representation of a Plenticore Switch for shadow management.
The shadow management switch can be controlled for each DC string separately. The DC string is
coded as bit in a single settings value, bit 0 for DC string 1, bit 1 for DC string 2, etc.
Not all DC strings are available for shadown management, for example if one of them is used
for a battery.
"""
_attr_entity_category = EntityCategory.CONFIG
entity_description: SwitchEntityDescription
MODULE_ID: Final = "devices:local"
SHADOW_DATA_ID: Final = "Generator:ShadowMgmt:Enable"
"""Settings id for the bit coded shadow management."""
DC_STRING_FEATURE_DATA_ID: Final = "Properties:String%dFeatures"
"""Settings id pattern for the DC string features."""
SHADOW_MANAGEMENT_SUPPORT: Final = 1
"""Feature value for shadow management support in the DC string features."""
def __init__(
self,
coordinator: SettingDataUpdateCoordinator,
dc_string: int,
entry_id: str,
platform_name: str,
device_info: DeviceInfo,
) -> None:
"""Create a new Switch Entity for Plenticore shadow management."""
super().__init__(coordinator, context=(self.MODULE_ID, self.SHADOW_DATA_ID))
self._mask: Final = 1 << dc_string
self.entity_description = SwitchEntityDescription(
key=f"ShadowMgmt{dc_string}",
name=f"Shadow Management DC string {dc_string + 1}",
entity_registry_enabled_default=False,
)
self.platform_name = platform_name
self._attr_name = f"{platform_name} {self.entity_description.name}"
self._attr_unique_id = (
f"{entry_id}_{self.MODULE_ID}_{self.SHADOW_DATA_ID}_{dc_string}"
)
self._attr_device_info = device_info
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.data is not None
and self.MODULE_ID in self.coordinator.data
and self.SHADOW_DATA_ID in self.coordinator.data[self.MODULE_ID]
)
def _get_shadow_mgmt_value(self) -> int:
"""Return the current shadow management value for all strings as integer."""
try:
return int(self.coordinator.data[self.MODULE_ID][self.SHADOW_DATA_ID])
except ValueError:
return 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn shadow management on."""
shadow_mgmt_value = self._get_shadow_mgmt_value()
shadow_mgmt_value |= self._mask
if await self.coordinator.async_write_data(
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
):
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn shadow management off."""
shadow_mgmt_value = self._get_shadow_mgmt_value()
shadow_mgmt_value &= ~self._mask
if await self.coordinator.async_write_data(
self.MODULE_ID, {self.SHADOW_DATA_ID: str(shadow_mgmt_value)}
):
await self.coordinator.async_request_refresh()
@property
def is_on(self) -> bool:
"""Return true if shadow management is on."""
return (self._get_shadow_mgmt_value() & self._mask) != 0

View File

@@ -1,6 +1,7 @@
"""Support for LCN binary sensors."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
import pypck
@@ -19,6 +20,7 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -69,21 +71,11 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity):
config[CONF_DOMAIN_DATA][CONF_SOURCE]
]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.bin_sensor_port
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
self.bin_sensor_port
)
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_binary_sensors(
SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""

View File

@@ -1,6 +1,8 @@
"""Support for LCN climate control."""
import asyncio
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any, cast
@@ -36,6 +38,7 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -110,20 +113,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.variable)
await self.device_connection.activate_status_request_handler(self.setpoint)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.variable)
await self.device_connection.cancel_status_request_handler(self.setpoint)
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
@@ -192,6 +181,17 @@ class LcnClimate(LcnEntity, ClimateEntity):
self._target_temperature = temperature
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await asyncio.gather(
self.device_connection.request_status_variable(
self.variable, SCAN_INTERVAL.seconds
),
self.device_connection.request_status_variable(
self.setpoint, SCAN_INTERVAL.seconds
),
)
def input_received(self, input_obj: InputType) -> None:
"""Set temperature value when LCN input object is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusVar):

View File

@@ -1,6 +1,8 @@
"""Support for LCN covers."""
import asyncio
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -27,6 +29,7 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -73,7 +76,7 @@ async def async_setup_entry(
class LcnOutputsCover(LcnEntity, CoverEntity):
"""Representation of a LCN cover connected to output ports."""
_attr_is_closed = False
_attr_is_closed = True
_attr_is_closing = False
_attr_is_opening = False
_attr_assumed_state = True
@@ -93,28 +96,6 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
else:
self.reverse_time = None
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTUP"]
)
await self.device_connection.activate_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTUP"]
)
await self.device_connection.cancel_status_request_handler(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"]
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
state = pypck.lcn_defs.MotorStateModifier.DOWN
@@ -147,6 +128,18 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
self._attr_is_opening = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
if not self.device_connection.is_group:
await asyncio.gather(
self.device_connection.request_status_output(
pypck.lcn_defs.OutputPort["OUTPUTUP"], SCAN_INTERVAL.seconds
),
self.device_connection.request_status_output(
pypck.lcn_defs.OutputPort["OUTPUTDOWN"], SCAN_INTERVAL.seconds
),
)
def input_received(self, input_obj: InputType) -> None:
"""Set cover states when LCN input object (command) is received."""
if (
@@ -175,7 +168,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
class LcnRelayCover(LcnEntity, CoverEntity):
"""Representation of a LCN cover connected to relays."""
_attr_is_closed = False
_attr_is_closed = True
_attr_is_closing = False
_attr_is_opening = False
_attr_assumed_state = True
@@ -206,20 +199,6 @@ class LcnRelayCover(LcnEntity, CoverEntity):
self._is_closing = False
self._is_opening = False
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.motor, self.positioning_mode
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.motor)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
if not await self.device_connection.control_motor_relays(
@@ -274,6 +253,17 @@ class LcnRelayCover(LcnEntity, CoverEntity):
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4:
coros.append(
self.device_connection.request_status_motor_position(
self.motor, self.positioning_mode, SCAN_INTERVAL.seconds
)
)
await asyncio.gather(*coros)
def input_received(self, input_obj: InputType) -> None:
"""Set cover states when LCN input object (command) is received."""
if isinstance(input_obj, pypck.inputs.ModStatusRelays):

View File

@@ -2,6 +2,8 @@
from collections.abc import Callable
from pypck.device import DeviceConnection
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -10,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_DOMAIN_DATA, DOMAIN
from .helpers import (
AddressType,
DeviceConnectionType,
InputType,
LcnConfigEntry,
generate_unique_id,
@@ -22,9 +23,8 @@ from .helpers import (
class LcnEntity(Entity):
"""Parent class for all entities associated with the LCN component."""
_attr_should_poll = False
_attr_has_entity_name = True
device_connection: DeviceConnectionType
device_connection: DeviceConnection
def __init__(
self,
@@ -57,15 +57,24 @@ class LcnEntity(Entity):
).lower(),
)
@property
def should_poll(self) -> bool:
"""Groups may not poll for a status."""
return not self.device_connection.is_group
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.device_connection = get_device_connection(
self.hass, self.config[CONF_ADDRESS], self.config_entry
)
if not self.device_connection.is_group:
self._unregister_for_inputs = self.device_connection.register_for_inputs(
self.input_received
)
if self.device_connection.is_group:
return
self._unregister_for_inputs = self.device_connection.register_for_inputs(
self.input_received
)
self.schedule_update_ha_state(force_refresh=True)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""

View File

@@ -11,6 +11,7 @@ from typing import cast
import pypck
from pypck.connection import PchkConnectionManager
from pypck.device import DeviceConnection
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -48,7 +49,7 @@ class LcnRuntimeData:
connection: PchkConnectionManager
"""Connection to PCHK host."""
device_connections: dict[str, DeviceConnectionType]
device_connections: dict[str, DeviceConnection]
"""Logical addresses of devices connected to the host."""
add_entities_callbacks: dict[str, Callable[[Iterable[ConfigType]], None]]
@@ -59,7 +60,6 @@ class LcnRuntimeData:
type LcnConfigEntry = ConfigEntry[LcnRuntimeData]
type AddressType = tuple[int, int, bool]
type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection
type InputType = type[pypck.inputs.Input]
@@ -82,11 +82,11 @@ DOMAIN_LOOKUP = {
def get_device_connection(
hass: HomeAssistant, address: AddressType, config_entry: LcnConfigEntry
) -> DeviceConnectionType:
) -> DeviceConnection:
"""Return a lcn device_connection."""
host_connection = config_entry.runtime_data.connection
addr = pypck.lcn_addr.LcnAddr(*address)
return host_connection.get_address_conn(addr)
return host_connection.get_device_connection(addr)
def get_resource(domain_name: str, domain_data: ConfigType) -> str:
@@ -246,18 +246,24 @@ def register_lcn_address_devices(
async def async_update_device_config(
device_connection: DeviceConnectionType, device_config: ConfigType
device_connection: DeviceConnection, device_config: ConfigType
) -> None:
"""Fill missing values in device_config with infos from LCN bus."""
# fetch serial info if device is module
if not (is_group := device_config[CONF_ADDRESS][2]): # is module
await device_connection.serial_known
await device_connection.serials_known()
if device_config[CONF_HARDWARE_SERIAL] == -1:
device_config[CONF_HARDWARE_SERIAL] = device_connection.hardware_serial
device_config[CONF_HARDWARE_SERIAL] = (
device_connection.serials.hardware_serial
)
if device_config[CONF_SOFTWARE_SERIAL] == -1:
device_config[CONF_SOFTWARE_SERIAL] = device_connection.software_serial
device_config[CONF_SOFTWARE_SERIAL] = (
device_connection.serials.software_serial
)
if device_config[CONF_HARDWARE_TYPE] == -1:
device_config[CONF_HARDWARE_TYPE] = device_connection.hardware_type.value
device_config[CONF_HARDWARE_TYPE] = (
device_connection.serials.hardware_type.value
)
# fetch name if device is module
if device_config[CONF_NAME] != "":

View File

@@ -1,6 +1,7 @@
"""Support for LCN lights."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -33,6 +34,7 @@ from .helpers import InputType, LcnConfigEntry
BRIGHTNESS_SCALE = (1, 100)
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_entities(
@@ -100,18 +102,6 @@ class LcnOutputLight(LcnEntity, LightEntity):
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = {self._attr_color_mode}
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if ATTR_TRANSITION in kwargs:
@@ -157,6 +147,12 @@ class LcnOutputLight(LcnEntity, LightEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_output(
self.output, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set light state when LCN input object (command) is received."""
if (
@@ -184,18 +180,6 @@ class LcnRelayLight(LcnEntity, LightEntity):
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
@@ -214,6 +198,10 @@ class LcnRelayLight(LcnEntity, LightEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set light state when LCN input object (command) is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):

View File

@@ -6,8 +6,8 @@
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"iot_class": "local_polling",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.8.12", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"]
}

View File

@@ -1,6 +1,7 @@
"""Support for LCN sensors."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from itertools import chain
@@ -40,6 +41,8 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE,
@@ -128,17 +131,11 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
)
self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.variable)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.variable)
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_variable(
self.variable, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""
@@ -170,17 +167,11 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity):
config[CONF_DOMAIN_DATA][CONF_SOURCE]
]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.source)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.source)
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_led_and_logic_ops(
SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set sensor value when LCN input object (command) is received."""

View File

@@ -3,6 +3,7 @@
from enum import StrEnum, auto
import pypck
from pypck.device import DeviceConnection
import voluptuous as vol
from homeassistant.const import (
@@ -48,7 +49,7 @@ from .const import (
VAR_UNITS,
VARIABLES,
)
from .helpers import DeviceConnectionType, LcnConfigEntry, is_states_string
from .helpers import LcnConfigEntry, is_states_string
class LcnServiceCall:
@@ -65,7 +66,7 @@ class LcnServiceCall:
"""Initialize service call."""
self.hass = hass
def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType:
def get_device_connection(self, service: ServiceCall) -> DeviceConnection:
"""Get address connection object."""
entries: list[LcnConfigEntry] = self.hass.config_entries.async_loaded_entries(
DOMAIN
@@ -380,9 +381,6 @@ class LockKeys(LcnServiceCall):
else:
await device_connection.lock_keys(table_id, states)
handler = device_connection.status_requests_handler
await handler.request_status_locked_keys_timeout()
class DynText(LcnServiceCall):
"""Send dynamic text to LCN-GTxD displays."""

View File

@@ -1,6 +1,7 @@
"""Support for LCN switches."""
from collections.abc import Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -17,6 +18,7 @@ from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(minutes=1)
def add_lcn_switch_entities(
@@ -77,18 +79,6 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if not await self.device_connection.dim_output(self.output.value, 100, 0):
@@ -103,6 +93,12 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_output(
self.output, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (
@@ -126,18 +122,6 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]]
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.output)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.output)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
@@ -156,6 +140,10 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
@@ -179,22 +167,6 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
]
self.reg_id = pypck.lcn_defs.Var.to_set_point_id(self.setpoint_variable)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(
self.setpoint_variable
)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(
self.setpoint_variable
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
if not await self.device_connection.lock_regulator(self.reg_id, True):
@@ -209,6 +181,12 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_variable(
self.setpoint_variable, SCAN_INTERVAL.seconds
)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (
@@ -234,18 +212,6 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
self.table_id = ord(self.key.name[0]) - 65
self.key_id = int(self.key.name[1]) - 1
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
if not self.device_connection.is_group:
await self.device_connection.activate_status_request_handler(self.key)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
if not self.device_connection.is_group:
await self.device_connection.cancel_status_request_handler(self.key)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
states = [pypck.lcn_defs.KeyLockStateModifier.NOCHANGE] * 8
@@ -268,6 +234,10 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity):
self._attr_is_on = False
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the state of the entity."""
await self.device_connection.request_status_locked_keys(SCAN_INTERVAL.seconds)
def input_received(self, input_obj: InputType) -> None:
"""Set switch state when LCN input object (command) is received."""
if (

View File

@@ -7,6 +7,7 @@ from functools import wraps
from typing import Any, Final
import lcn_frontend as lcn_panel
from pypck.device import DeviceConnection
import voluptuous as vol
from homeassistant.components import panel_custom, websocket_api
@@ -37,7 +38,6 @@ from .const import (
DOMAIN,
)
from .helpers import (
DeviceConnectionType,
LcnConfigEntry,
async_update_device_config,
generate_unique_id,
@@ -182,7 +182,7 @@ async def websocket_scan_devices(
host_connection = config_entry.runtime_data.connection
await host_connection.scan_modules()
for device_connection in host_connection.address_conns.values():
for device_connection in host_connection.device_connections.values():
if not device_connection.is_group:
await async_create_or_update_device_in_config_entry(
hass, device_connection, config_entry
@@ -421,7 +421,7 @@ async def websocket_delete_entity(
async def async_create_or_update_device_in_config_entry(
hass: HomeAssistant,
device_connection: DeviceConnectionType,
device_connection: DeviceConnection,
config_entry: LcnConfigEntry,
) -> None:
"""Create or update device in config_entry according to given device_connection."""

View File

@@ -154,6 +154,7 @@ class LocalTodoListEntity(TodoListEntity):
),
due=due,
description=item.description,
completed=item.completed,
)
)
self._attr_todo_items = todo_items

View File

@@ -194,7 +194,7 @@
"name": "Altitude above sea level"
},
"auto_relock_timer": {
"name": "Autorelock time"
"name": "Auto-relock time"
},
"cook_time": {
"name": "Cooking time"

View File

@@ -139,7 +139,7 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
forecasts_by_date[date].append(timestamp)
daily_forecasts = []
for date in sorted(forecasts_by_date.keys())[:5]:
for date in sorted(forecasts_by_date.keys()):
day_forecasts = forecasts_by_date[date]
if not day_forecasts:
continue
@@ -186,5 +186,5 @@ class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherE
return None
return [
self._convert_forecast_data(forecast_data)
for forecast_data in self.coordinator.data.forecast_timestamps[:24]
for forecast_data in self.coordinator.data.forecast_timestamps
]

View File

@@ -6,7 +6,7 @@ from dataclasses import dataclass
import logging
from typing import Final
import aiohttp
from aiohttp import ClientResponseError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
@@ -153,11 +153,12 @@ class MieleButton(MieleEntity, ButtonEntity):
self._device_id,
{PROCESS_ACTION: self.entity_description.press_data},
)
except aiohttp.ClientResponseError as ex:
except ClientResponseError as err:
_LOGGER.debug("Error setting button state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final, cast
import aiohttp
from aiohttp import ClientResponseError
from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.climate import (
@@ -250,7 +250,8 @@ class MieleClimate(MieleEntity, ClimateEntity):
cast(float, kwargs.get(ATTR_TEMPERATURE)),
self.entity_description.zone,
)
except aiohttp.ClientError as err:
except ClientResponseError as err:
_LOGGER.debug("Error setting climate state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

@@ -73,7 +73,7 @@ class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
_LOGGER.debug(
"Error fetching actions for device %s: Status: %s, Message: %s",
device_id,
err.status,
str(err.status),
err.message,
)
actions_json = {}

View File

@@ -142,14 +142,15 @@ class MieleFan(MieleEntity, FanEntity):
await self.api.send_action(
self._device_id, {VENTILATION_STEP: ventilation_step}
)
except ClientResponseError as ex:
except ClientResponseError as err:
_LOGGER.debug("Error setting fan state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err
self.device.state_ventilation_step = ventilation_step
self.async_write_ha_state()
@@ -171,6 +172,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
"err_status": str(ex.status),
},
) from ex
@@ -188,6 +190,7 @@ class MieleFan(MieleEntity, FanEntity):
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
"err_status": str(ex.status),
},
) from ex

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final
import aiohttp
from aiohttp import ClientResponseError
from homeassistant.components.light import (
ColorMode,
@@ -131,7 +131,8 @@ class MieleLight(MieleEntity, LightEntity):
await self.api.send_action(
self._device_id, {self.entity_description.light_type: mode}
)
except aiohttp.ClientError as err:
except ClientResponseError as err:
_LOGGER.debug("Error setting light state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

@@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import cast
import aiohttp
from aiohttp import ClientResponseError
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_TEMPERATURE
@@ -107,7 +107,7 @@ async def set_program(call: ServiceCall) -> None:
data = {"programId": call.data[ATTR_PROGRAM_ID]}
try:
await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex:
except ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_program_error",
@@ -137,7 +137,7 @@ async def set_program_oven(call: ServiceCall) -> None:
data["temperature"] = call.data[ATTR_TEMPERATURE]
try:
await api.set_program(serial_number, data)
except aiohttp.ClientResponseError as ex:
except ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_program_oven_error",
@@ -157,7 +157,7 @@ async def get_programs(call: ServiceCall) -> ServiceResponse:
try:
programs = await api.get_programs(serial_number)
except aiohttp.ClientResponseError as ex:
except ClientResponseError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="get_programs_error",

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Any, Final, cast
import aiohttp
from aiohttp import ClientResponseError
from pymiele import MieleDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@@ -165,7 +165,8 @@ class MieleSwitch(MieleEntity, SwitchEntity):
"""Set switch to mode."""
try:
await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err:
except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
@@ -197,7 +198,8 @@ class MielePowerSwitch(MieleSwitch):
"""Set switch to mode."""
try:
await self.api.send_action(self._device_id, mode)
except aiohttp.ClientError as err:
except ClientResponseError as err:
_LOGGER.debug("Error setting switch state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",

View File

@@ -189,14 +189,15 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
"""Send action to the device."""
try:
await self.api.send_action(device_id, action)
except ClientResponseError as ex:
except ClientResponseError as err:
_LOGGER.debug("Error setting vacuum state for %s: %s", self.entity_id, err)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_state_error",
translation_placeholders={
"entity": self.entity_id,
},
) from ex
) from err
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Clean spot."""

View File

@@ -41,9 +41,11 @@ from .const import (
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_PENDING_UPDATES,
DATA_PUSH_CHANNEL,
DATA_STORE,
DOMAIN,
SENSOR_TYPES,
STORAGE_KEY,
STORAGE_VERSION,
)
@@ -75,6 +77,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DATA_DEVICES: {},
DATA_PUSH_CHANNEL: {},
DATA_STORE: store,
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
}
hass.http.register_view(RegistrationsView())

View File

@@ -4,7 +4,7 @@ from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON
from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -75,8 +75,9 @@ class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity):
async def async_restore_last_state(self, last_state: State) -> None:
"""Restore previous state."""
await super().async_restore_last_state(last_state)
self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON
if self._config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
await super().async_restore_last_state(last_state)
self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON
self._async_update_attr_from_config()
@callback

View File

@@ -20,6 +20,7 @@ DATA_DEVICES = "devices"
DATA_STORE = "store"
DATA_NOTIFY = "notify"
DATA_PUSH_CHANNEL = "push_channel"
DATA_PENDING_UPDATES = "pending_updates"
ATTR_APP_DATA = "app_data"
ATTR_APP_ID = "app_id"
@@ -94,3 +95,5 @@ SCHEMA_APP_DATA = vol.Schema(
},
extra=vol.ALLOW_EXTRA,
)
SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR)

View File

@@ -2,10 +2,16 @@
from __future__ import annotations
from typing import Any
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE
from homeassistant.const import (
ATTR_ICON,
CONF_NAME,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
@@ -18,10 +24,15 @@ from .const import (
ATTR_SENSOR_ICON,
ATTR_SENSOR_STATE,
ATTR_SENSOR_STATE_CLASS,
ATTR_SENSOR_TYPE,
DATA_PENDING_UPDATES,
DOMAIN,
SIGNAL_SENSOR_UPDATE,
)
from .helpers import device_info
_LOGGER = logging.getLogger(__name__)
class MobileAppEntity(RestoreEntity):
"""Representation of a mobile app entity."""
@@ -56,11 +67,14 @@ class MobileAppEntity(RestoreEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}",
f"{SIGNAL_SENSOR_UPDATE}-{self._config[ATTR_SENSOR_TYPE]}-{self._attr_unique_id}",
self._handle_update,
)
)
# Apply any pending updates
self._handle_update()
if (state := await self.async_get_last_state()) is None:
return
@@ -69,13 +83,16 @@ class MobileAppEntity(RestoreEntity):
async def async_restore_last_state(self, last_state: State) -> None:
"""Restore previous state."""
config = self._config
config[ATTR_SENSOR_STATE] = last_state.state
config[ATTR_SENSOR_ATTRIBUTES] = {
**last_state.attributes,
**self._config[ATTR_SENSOR_ATTRIBUTES],
}
if ATTR_ICON in last_state.attributes:
config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
# Only restore state if we don't have one already, since it can be set by a pending update
if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
config[ATTR_SENSOR_STATE] = last_state.state
config[ATTR_SENSOR_ATTRIBUTES] = {
**last_state.attributes,
**self._config[ATTR_SENSOR_ATTRIBUTES],
}
if ATTR_ICON in last_state.attributes:
config[ATTR_SENSOR_ICON] = last_state.attributes[ATTR_ICON]
@property
def device_info(self):
@@ -83,8 +100,21 @@ class MobileAppEntity(RestoreEntity):
return device_info(self._registration)
@callback
def _handle_update(self, data: dict[str, Any]) -> None:
def _handle_update(self) -> None:
"""Handle async event updates."""
self._config.update(data)
self._apply_pending_update()
self._async_update_attr_from_config()
self.async_write_ha_state()
def _apply_pending_update(self) -> None:
"""Restore any pending update for this entity."""
entity_type = self._config[ATTR_SENSOR_TYPE]
pending_updates = self.hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type]
if update := pending_updates.pop(self._attr_unique_id, None):
_LOGGER.debug(
"Applying pending update for %s: %s",
self._attr_unique_id,
update,
)
# Apply the pending update
self._config.update(update)

View File

@@ -86,24 +86,26 @@ class MobileAppSensor(MobileAppEntity, RestoreSensor):
async def async_restore_last_state(self, last_state: State) -> None:
"""Restore previous state."""
await super().async_restore_last_state(last_state)
config = self._config
if not (last_sensor_data := await self.async_get_last_sensor_data()):
# Workaround to handle migration to RestoreSensor, can be removed
# in HA Core 2023.4
config[ATTR_SENSOR_STATE] = None
webhook_id = self._entry.data[CONF_WEBHOOK_ID]
if TYPE_CHECKING:
assert self.unique_id is not None
sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id)
if (
self.device_class == SensorDeviceClass.TEMPERATURE
and sensor_unique_id == "battery_temperature"
):
config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS
else:
config[ATTR_SENSOR_STATE] = last_sensor_data.native_value
config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement
if config[ATTR_SENSOR_STATE] in (None, STATE_UNKNOWN):
await super().async_restore_last_state(last_state)
if not (last_sensor_data := await self.async_get_last_sensor_data()):
# Workaround to handle migration to RestoreSensor, can be removed
# in HA Core 2023.4
config[ATTR_SENSOR_STATE] = None
webhook_id = self._entry.data[CONF_WEBHOOK_ID]
if TYPE_CHECKING:
assert self.unique_id is not None
sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id)
if (
self.device_class == SensorDeviceClass.TEMPERATURE
and sensor_unique_id == "battery_temperature"
):
config[ATTR_SENSOR_UOM] = UnitOfTemperature.CELSIUS
else:
config[ATTR_SENSOR_STATE] = last_sensor_data.native_value
config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement
self._async_update_attr_from_config()

View File

@@ -79,7 +79,6 @@ from .const import (
ATTR_SENSOR_STATE,
ATTR_SENSOR_STATE_CLASS,
ATTR_SENSOR_TYPE,
ATTR_SENSOR_TYPE_BINARY_SENSOR,
ATTR_SENSOR_TYPE_SENSOR,
ATTR_SENSOR_UNIQUE_ID,
ATTR_SENSOR_UOM,
@@ -98,12 +97,14 @@ from .const import (
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_PENDING_UPDATES,
DOMAIN,
ERR_ENCRYPTION_ALREADY_ENABLED,
ERR_ENCRYPTION_REQUIRED,
ERR_INVALID_FORMAT,
ERR_SENSOR_NOT_REGISTERED,
SCHEMA_APP_DATA,
SENSOR_TYPES,
SIGNAL_LOCATION_UPDATE,
SIGNAL_SENSOR_UPDATE,
)
@@ -125,8 +126,6 @@ WEBHOOK_COMMANDS: Registry[
str, Callable[[HomeAssistant, ConfigEntry, Any], Coroutine[Any, Any, Response]]
] = Registry()
SENSOR_TYPES = (ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR)
WEBHOOK_PAYLOAD_SCHEMA = vol.Any(
vol.Schema(
{
@@ -601,14 +600,16 @@ async def webhook_register_sensor(
if changes:
entity_registry.async_update_entity(existing_sensor, **changes)
async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data)
_async_update_sensor_entity(
hass, entity_type=entity_type, unique_store_key=unique_store_key, data=data
)
else:
data[CONF_UNIQUE_ID] = unique_store_key
data[CONF_NAME] = (
f"{config_entry.data[ATTR_DEVICE_NAME]} {data[ATTR_SENSOR_NAME]}"
)
register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register"
register_signal = f"{DOMAIN}_{entity_type}_register"
async_dispatcher_send(hass, register_signal, data)
return webhook_response(
@@ -685,10 +686,12 @@ async def webhook_update_sensor_states(
continue
sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID]
async_dispatcher_send(
_async_update_sensor_entity(
hass,
f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}",
sensor,
entity_type=entity_type,
unique_store_key=unique_store_key,
data=sensor,
)
resp[unique_id] = {"success": True}
@@ -697,11 +700,26 @@ async def webhook_update_sensor_states(
entry = entity_registry.async_get(entity_id)
if entry and entry.disabled_by:
# Inform the app that the entity is disabled
resp[unique_id]["is_disabled"] = True
return webhook_response(resp, registration=config_entry.data)
def _async_update_sensor_entity(
hass: HomeAssistant, entity_type: str, unique_store_key: str, data: dict[str, Any]
) -> None:
"""Update a sensor entity with new data."""
# Replace existing pending update with the latest sensor data.
hass.data[DOMAIN][DATA_PENDING_UPDATES][entity_type][unique_store_key] = data
# The signal might not be handled if the entity was just enabled, but the data is stored
# in pending updates and will be applied on entity initialization.
async_dispatcher_send(
hass, f"{SIGNAL_SENSOR_UPDATE}-{entity_type}-{unique_store_key}"
)
@WEBHOOK_COMMANDS.register("get_zones")
async def webhook_get_zones(
hass: HomeAssistant, config_entry: ConfigEntry, data: Any

View File

@@ -88,6 +88,8 @@ class ModbusRegisterSensor(ModbusStructEntity, RestoreSensor, SensorEntity):
self._attr_device_class = entry.get(CONF_DEVICE_CLASS)
if self._precision > 0 or self._scale != int(self._scale):
self._value_is_int = False
if self._precision > 0 and self._data_type not in ["string", "custom"]:
self._attr_suggested_display_precision = self._precision
async def async_setup_slaves(
self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any]

View File

@@ -4237,7 +4237,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
return self.async_show_form(
step_id="entity",
data_schema=data_schema,
description_placeholders={
description_placeholders=TRANSLATION_DESCRIPTION_PLACEHOLDERS
| {
"mqtt_device": device_name,
"entity_name_label": entity_name_label,
"platform_label": platform_label,

View File

@@ -1312,6 +1312,7 @@
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",

View File

@@ -27,7 +27,8 @@ from homeassistant.helpers.issue_registry import (
)
from .const import ATTR_CONF_EXPOSE_PLAYER_TO_HA, DOMAIN, LOGGER
from .services import get_music_assistant_client, register_actions
from .helpers import get_music_assistant_client
from .services import register_actions
if TYPE_CHECKING:
from music_assistant_models.event import MassEvent

View File

@@ -4,11 +4,18 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from typing import TYPE_CHECKING, Any
from music_assistant_models.errors import MusicAssistantError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from . import MusicAssistantConfigEntry
def catch_musicassistant_error[**_P, _R](
@@ -26,3 +33,16 @@ def catch_musicassistant_error[**_P, _R](
raise HomeAssistantError(error_msg) from err
return wrapper
@callback
def get_music_assistant_client(
hass: HomeAssistant, config_entry_id: str
) -> MusicAssistantClient:
"""Get the Music Assistant client for the given config entry."""
entry: MusicAssistantConfigEntry | None
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
return entry.runtime_data.mass

View File

@@ -22,11 +22,9 @@ from music_assistant_models.errors import MediaNotFoundError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
from music_assistant_models.player_queue import PlayerQueue
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseMedia,
MediaPlayerDeviceClass,
@@ -41,38 +39,26 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.const import ATTR_NAME, STATE_OFF, Platform
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.core import HomeAssistant, ServiceResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp
from . import MusicAssistantConfigEntry
from .const import (
ATTR_ACTIVE,
ATTR_ACTIVE_QUEUE,
ATTR_ALBUM,
ATTR_ANNOUNCE_VOLUME,
ATTR_ARTIST,
ATTR_AUTO_PLAY,
ATTR_CURRENT_INDEX,
ATTR_CURRENT_ITEM,
ATTR_ELAPSED_TIME,
ATTR_ITEMS,
ATTR_MASS_PLAYER_TYPE,
ATTR_MEDIA_ID,
ATTR_MEDIA_TYPE,
ATTR_NEXT_ITEM,
ATTR_QUEUE_ID,
ATTR_RADIO_MODE,
ATTR_REPEAT_MODE,
ATTR_SHUFFLE_ENABLED,
ATTR_SOURCE_PLAYER,
ATTR_URL,
ATTR_USE_PRE_ANNOUNCE,
DOMAIN,
)
from .entity import MusicAssistantEntity
@@ -122,11 +108,6 @@ REPEAT_MODE_MAPPING_TO_HA = {
# UNKNOWN is intentionally not mapped - will return None
}
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
SERVICE_TRANSFER_QUEUE = "transfer_queue"
SERVICE_GET_QUEUE = "get_queue"
async def async_setup_entry(
hass: HomeAssistant,
@@ -143,44 +124,6 @@ async def async_setup_entry(
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player)
# add platform service for play_media with advanced options
platform = async_get_current_platform()
platform.async_register_entity_service(
SERVICE_PLAY_MEDIA_ADVANCED,
{
vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
vol.Optional(ATTR_ARTIST): cv.string,
vol.Optional(ATTR_ALBUM): cv.string,
vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
},
"_async_handle_play_media",
)
platform.async_register_entity_service(
SERVICE_PLAY_ANNOUNCEMENT,
{
vol.Required(ATTR_URL): cv.string,
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
},
"_async_handle_play_announcement",
)
platform.async_register_entity_service(
SERVICE_TRANSFER_QUEUE,
{
vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
},
"_async_handle_transfer_queue",
)
platform.async_register_entity_service(
SERVICE_GET_QUEUE,
schema=None,
func="_async_handle_get_queue",
supports_response=SupportsResponse.ONLY,
)
class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Representation of MediaPlayerEntity from Music Assistant Player."""

View File

@@ -4,10 +4,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from music_assistant_models.enums import MediaType
from music_assistant_models.enums import MediaType, QueueOption
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
DOMAIN as MEDIA_PLAYER_DOMAIN,
)
from homeassistant.const import ATTR_CONFIG_ENTRY_ID
from homeassistant.core import (
HomeAssistant,
@@ -17,31 +20,41 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_ALBUM,
ATTR_ALBUM_ARTISTS_ONLY,
ATTR_ALBUM_TYPE,
ATTR_ALBUMS,
ATTR_ANNOUNCE_VOLUME,
ATTR_ARTIST,
ATTR_ARTISTS,
ATTR_AUDIOBOOKS,
ATTR_AUTO_PLAY,
ATTR_FAVORITE,
ATTR_ITEMS,
ATTR_LIBRARY_ONLY,
ATTR_LIMIT,
ATTR_MEDIA_ID,
ATTR_MEDIA_TYPE,
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
ATTR_PODCASTS,
ATTR_RADIO,
ATTR_RADIO_MODE,
ATTR_SEARCH,
ATTR_SEARCH_ALBUM,
ATTR_SEARCH_ARTIST,
ATTR_SEARCH_NAME,
ATTR_SOURCE_PLAYER,
ATTR_TRACKS,
ATTR_URL,
ATTR_USE_PRE_ANNOUNCE,
DOMAIN,
)
from .helpers import get_music_assistant_client
from .schemas import (
LIBRARY_RESULTS_SCHEMA,
SEARCH_RESULT_SCHEMA,
@@ -49,7 +62,6 @@ from .schemas import (
)
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.media_items import (
Album,
Artist,
@@ -60,28 +72,18 @@ if TYPE_CHECKING:
Track,
)
from . import MusicAssistantConfigEntry
SERVICE_SEARCH = "search"
SERVICE_GET_LIBRARY = "get_library"
SERVICE_PLAY_MEDIA_ADVANCED = "play_media"
SERVICE_PLAY_ANNOUNCEMENT = "play_announcement"
SERVICE_TRANSFER_QUEUE = "transfer_queue"
SERVICE_GET_QUEUE = "get_queue"
DEFAULT_OFFSET = 0
DEFAULT_LIMIT = 25
DEFAULT_SORT_ORDER = "name"
@callback
def get_music_assistant_client(
hass: HomeAssistant, config_entry_id: str
) -> MusicAssistantClient:
"""Get the Music Assistant client for the given config entry."""
entry: MusicAssistantConfigEntry | None
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError("Entry not found")
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError("Entry not loaded")
return entry.runtime_data.mass
@callback
def register_actions(hass: HomeAssistant) -> None:
"""Register custom actions."""
@@ -124,6 +126,55 @@ def register_actions(hass: HomeAssistant) -> None:
supports_response=SupportsResponse.ONLY,
)
# Platform entity services
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_PLAY_MEDIA_ADVANCED,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_MEDIA_ID): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_MEDIA_TYPE): vol.Coerce(MediaType),
vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Coerce(QueueOption),
vol.Optional(ATTR_ARTIST): cv.string,
vol.Optional(ATTR_ALBUM): cv.string,
vol.Optional(ATTR_RADIO_MODE): vol.Coerce(bool),
},
func="_async_handle_play_media",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_PLAY_ANNOUNCEMENT,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Required(ATTR_URL): cv.string,
vol.Optional(ATTR_USE_PRE_ANNOUNCE): vol.Coerce(bool),
vol.Optional(ATTR_ANNOUNCE_VOLUME): vol.Coerce(int),
},
func="_async_handle_play_announcement",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_TRANSFER_QUEUE,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema={
vol.Optional(ATTR_SOURCE_PLAYER): cv.entity_id,
vol.Optional(ATTR_AUTO_PLAY): vol.Coerce(bool),
},
func="_async_handle_transfer_queue",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_GET_QUEUE,
entity_domain=MEDIA_PLAYER_DOMAIN,
schema=None,
func="_async_handle_get_queue",
supports_response=SupportsResponse.ONLY,
)
async def handle_search(call: ServiceCall) -> ServiceResponse:
"""Handle queue_command action."""

View File

@@ -5,5 +5,6 @@
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
"loggers": ["MVG"],
"quality_scale": "legacy",
"requirements": ["mvg==1.4.0"]
}

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