Compare commits

...

99 Commits

Author SHA1 Message Date
Marc Mueller d19e012664 Use ubuntu-24.04 for armv7 2025-06-05 01:52:32 +02:00
Marc Mueller f54d0ccbd7 Build rpds-py 0.25.1 2025-06-05 01:51:51 +02:00
Marc Mueller fd500346bf Only build custom wheels 2025-06-05 01:43:57 +02:00
Markus Adrario c6c7e7eae1 Add homee reconfiguration flow (#146065)
* Add a reconfigure flow to homee

* Add tests for reconfiguration flow

* string refinement

* fix review comments

* more review fixes
2025-06-04 15:27:07 +02:00
Iskra kranj 07557e27b0 Bump pyiskra to 0.1.21 (#146156) 2025-06-04 14:51:40 +02:00
J. Nick Koston f211da60e0 Bump aiohttp to 3.12.8 (#146153) 2025-06-04 12:57:40 +01:00
Michael 64b74d00f7 Bump aioimmich to 0.9.0 (#146154)
bump aioimmich to 0.9.0
2025-06-04 13:35:16 +02:00
J. Nick Koston 96cb645644 Bump aioesphomeapi to 32.0.0 (#146135) 2025-06-04 09:34:04 +01:00
Claudio Ruggeri - CR-Tech 9b0db3bd51 Bump pymodbus to 3.9.2 (#145948) 2025-06-04 10:28:34 +02:00
Robert Resch ffdefd1e0f Deprecate eddystone temperature integration (#145833) 2025-06-04 10:00:50 +02:00
Max Velitchko 59ad0268a9 Bump pyvera to 0.3.16 (#146089)
* Update vera integration with the latest pyvera package

* python3 -m script.gen_requirements_all

* Fix license
2025-06-04 07:47:41 +01:00
dependabot[bot] f28851e76f Bump github/codeql-action from 3.28.18 to 3.28.19 (#146131)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.28.18 to 3.28.19.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3.28.18...v3.28.19)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 3.28.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-04 07:41:34 +01:00
J. Nick Koston 4f5c1d544b Bump protobuf to 6.31.1 (#146128)
changelog: https://github.com/protocolbuffers/protobuf/compare/v30.2...v31.1
2025-06-04 07:40:10 +01:00
Marc Mueller a8ccf1c6fc Update pytest to 8.4.0 (#146114) 2025-06-04 08:09:19 +02:00
Ian e3f7e5706b Add config option for controlling Ollama think parameter (#146000)
* Add config option for controlling Ollama think parameter

Allows enabling or disable thinking for supported models. Neither option
will dislay thinking content in the chat. Future support for displaying
think content will require frontend changes for formatting.

* Add thinking strings
2025-06-03 20:42:16 -07:00
Erwin Douna 7ad1e756e7 SMA fix strings (#146112)
* Fix

* Feedback
2025-06-03 21:54:44 +02:00
Norbert Rittel 8868f214f3 Replace "numbers" with "digits" in invalid_backbone_key message of knx (#146124)
The KNX Backbone Key has a length of 128 bits, so written as a hexadecimal number that yields 32 digits.

This fix thus replaces "numbers" with "digits" in the `invalid_backbone_key` message.
2025-06-03 20:47:54 +02:00
J. Nick Koston 3ecff19a45 Bump habluetooth to 3.49.0 (#146111)
* Bump habluetooth to 3.49.0

changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.48.2...v3.49.0

* update diag

* diag
2025-06-03 16:56:20 +02:00
Ian 74421db747 NextBus: Bump py_nextbusnext to 2.2.0 (#145904) 2025-06-03 13:20:14 +02:00
J. Nick Koston 1cccfac3dc Bump bleak-esphome to 2.16.0 (#146110) 2025-06-03 11:57:58 +01:00
David Bonnes c254548a64 Add required_features to WaterHeater entity service registrations (#141873) 2025-06-03 12:51:46 +02:00
epenet 7f8b782e95 Adjust SamsungTV on/off logging (#146045)
* Adjust SamsungTV on/off logging

* Update coordinator.py
2025-06-03 12:30:18 +02:00
Erwin Douna cd518d4a46 SMA add missing strings for DHCP (#145782) 2025-06-03 12:12:56 +02:00
Retha Runolfsson c5db07e84d Fix nightlatch option for all switchbot locks (#146090) 2025-06-03 12:11:02 +02:00
epenet d1e0225520 Adjust ConnectionFailure logging in SamsungTV (#146044) 2025-06-03 12:05:33 +02:00
Robin Lintermann d439bb68eb Smarla integration improve tests (#145803)
* Improve smarla integration tests

* Do not import descriptions instead use seperate list
2025-06-03 11:49:24 +02:00
Matthias Alphart 980dbf364d Add exception translations for KNX services (#146104) 2025-06-03 11:31:32 +02:00
SNoof85 842e7ce171 Add state class measurement to Freebox temperature sensors (#146074) 2025-06-03 11:23:52 +02:00
epenet 8afec8ada9 Use async_load_fixture in youtube tests (#146018) 2025-06-03 11:07:56 +02:00
Simone Chemelli 7b699f7733 Avoid services unload for Homematicip Cloud (#146050)
* Avoid services unload

* fix tests

* apply review comments

* cleanup

* apply review comment
2025-06-03 11:01:23 +02:00
Noah Groß d448ef9f16 Bump python-picnic-api2 to 1.3.1 (#145962) 2025-06-03 10:57:59 +02:00
epenet 03912a1704 Use async_load_fixture in tplink_omada tests (#146014) 2025-06-03 10:54:22 +02:00
epenet 54c20d5d5a Use async_load_fixture in remaining tests (#146021) 2025-06-03 10:52:51 +02:00
epenet 2dbf24e798 Use async_load_fixture in skybell tests (#146017) 2025-06-03 10:47:03 +02:00
epenet 791654a420 Move services to separate module in nzbget (#146093) 2025-06-03 10:41:40 +02:00
epenet 5fe07e49e4 Move services to separate module in insteon (#146094) 2025-06-03 10:41:13 +02:00
epenet 0bd287788c Move service registration to async_setup in icloud (#146095) 2025-06-03 10:40:48 +02:00
Brett Adams 40e0c0f98d Fix BMS and Charge states in Teslemetry (#146091)
Fix BMS and Charge states
2025-06-03 10:40:20 +02:00
Pär Holmdahl 85b608912b Add energy sensor to adax (#145995)
* 2nd attempt to add energysensors to Adax component

* Ruff format changes

* I did not reuse the first call for information.. Now i do..

* Fixed some tests after the last change

* Remove extra attributes

* Dont use info logger

* aggregate if not rooms

* Raise error if no rooms are discovered

* Move code out of try catch

* Catch more specific errors

* removed platforms from manifest.json

* remove attribute translation key

* Getting rid of the summation of energy used..

* Fixed errorness in test

* set roomproperty in Init

* concatenated the two functions

* use raw Wh values and suggest a konversion for HomeAssistant

* Use snapshot testing

* Update homeassistant/components/adax/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/strings.json

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Removing un needed logg

* Removing initial value

* Changing tests to snapshot_platform

* Removing available property from sensor.py and doing a ruff formating..

* Fix a broken indent

* Add fix for coordinator updates in Adax energisensor and namesetting

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/coordinator.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* generated snapshots

* Ruff changes

* Even more ruff changes, that did not appear on ruff command locally

* Trying to fix CI updates

* Update homeassistant/components/adax/sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Improve AdaxEnergySensor by simplifying code and ensuring correct handling of energy values. Adjust how room and device information is retrieved to avoid duplication and improve readability.

* Removed a test för device_id as per request..

* Make supersure that value is int and not "Any"

* removing executable status

* Update tests/components/adax/test_sensor.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

---------

Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-06-03 10:36:43 +02:00
Pete Sage 987753dd1c Bump aiokem to 1.0.1 (#146085) 2025-06-03 10:16:08 +02:00
epenet 5df05fb6dd Move async_register_services to async_setup (#146092) 2025-06-03 08:38:02 +02:00
Simone Chemelli f295ca27af Bump aioamazondevices to 3.0.5 (#146073) 2025-06-03 01:18:49 +03:00
Marc Mueller 8f75cc6a33 Update pyatmo to 9.2.1 (#146077) 2025-06-02 23:47:50 +02:00
Marc Mueller 19c71f0f49 Update python-homewizard-energy to 8.3.3 (#146076) 2025-06-02 23:34:50 +02:00
Marc Mueller 22c2028c00 Update typing-extensions to 4.14.0 (#146054) 2025-06-02 23:15:53 +02:00
Ian 39f687e3a3 Bump ollama to 0.5.1 (#146063)
* Bump ollama to 0.5.1
* Add ollama to license exceptions
2025-06-02 22:43:00 +02:00
Shay Levy 6692b9b71f Fix Shelly BLU TRV calibrate button (#146066) 2025-06-02 22:38:17 +03:00
J. Nick Koston 2f5787e7be Bump aiohttp to 3.12.7 (#146028) 2025-06-02 21:27:08 +02:00
Simone Chemelli bbda1761bf Avoid services unload for Isy994 (#146069)
* Avoid services unload for Isy994

* cleanup
2025-06-02 21:19:10 +02:00
Robert Resch ecc10e9793 Bump go2rtc-client to 0.2.1 (#146019)
* Bump go2rtc-client to 0.2.0

* Bump go2rtc-client to 0.2.1

* Clean up hassfest exception

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-02 20:48:40 +02:00
Simone Chemelli 9e1e889fd7 Rename mispelled services python files (#146049) 2025-06-02 20:41:31 +02:00
Michael eefe1e6f0f Don't use multi-line conditionals in immich (#146062) 2025-06-02 19:58:54 +02:00
Marc Mueller 397ed87f2d Update aiohomekit to 3.2.15 (#146059)
* Update aiohomekit to 3.2.15

* Remove Python version exception for homekit_controller
2025-06-02 18:23:04 +01:00
Marc Mueller 15830f383e Update pyoverkiz to 1.17.2 (#146056) 2025-06-02 18:21:26 +01:00
epenet 87395efc6e Add awesomeversion to dependency version checks (#146047) 2025-06-02 17:28:13 +02:00
Marc Mueller 27d79bb10a Update yamllint to 1.37.1 (#146038) 2025-06-02 16:35:31 +02:00
Simone Chemelli 7427db70aa Move async_setup_services to async_setup (#146048)
* Moved async_setup_services to async_setup

* fix schema missing
2025-06-02 16:23:20 +02:00
Marc Mueller 77d5bffa85 Update pytest warnings filter (#146024) 2025-06-02 16:01:23 +02:00
Marc Mueller ab7c7b8d89 Update ruff to 0.11.12 (#146037)
* Update ruff to 0.11.12
* Replace ruff legacy alias with ruff-check
2025-06-02 16:01:10 +02:00
Simon Lamon 93b8cc38d8 Small nmbs sensor attributes refactoring (#145956)
Attributes refactoring
2025-06-02 15:13:23 +02:00
Pete Sage e5f95b3aff Add diagnostics tests for Sonos (#146040)
* fix: add tests for diagnostics

* fix: add new files

* fix: add new files
2025-06-02 15:12:34 +02:00
starkillerOG 613728ad3b Improve debug logging Reolink (#146033)
Add debug logging
2025-06-02 15:12:13 +02:00
starkillerOG cb1bfe6ebe Bump reolink-aio to 0.13.5 (#145974)
* Add debug logging

* Bump reolink-aio to 0.13.5

* Revert "Add debug logging"

This reverts commit f96030a6c8dccca7888b6d1274d5ed3a251ac03c.
2025-06-02 15:11:56 +02:00
Joost Lekkerkerker 434179ab3f Remove NMBS YAML import (#145733)
* Remove NMBS YAML import

* Remove NMBS YAML import
2025-06-02 15:10:46 +02:00
TimL eb53277fcc Bump pysmlight to 0.2.6 (#146039)
Co-authored-by: Tim Lunn <tim@feathertop.org>
2025-06-02 15:04:34 +02:00
J. Nick Koston 850ddb3667 Bump grpcio to 1.72.1 (#146029) 2025-06-02 15:04:02 +02:00
epenet 5a727a4fa3 Avoid constant alias for integration DOMAIN (#145788)
* Avoid constant alias for integration DOMAIN

* Tweak

* Improve

* Three more

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-06-02 10:37:29 +02:00
karwosts 33fc700952 Make sun solar_rising a binary_sensor (#140956)
* Make sun solar_rising a binary_sensor.

* Add a state translation

* code review

* fix test

* move PLATFORMS

* Update strings.json
2025-06-02 10:32:48 +02:00
Joakim Sørensen ad493e077e Submit legacy integrations for analytics (#145787)
* Submit legacy integrations for analytics

* adjustments
2025-06-02 10:29:17 +02:00
Marc Mueller a2b2f6f20a Update pre-commit to 4.2.0 (#145986) 2025-06-02 09:56:20 +02:00
Marc Mueller ee57fd413a Update freezegun to 1.5.2 (#145982) 2025-06-02 09:53:12 +02:00
Martin Hjelmare f5d585e0f0 Fix removal of devices during Z-Wave migration (#145867) 2025-06-02 09:52:02 +02:00
Simone Chemelli 1899388f35 Add diagnostics to Amazon devices (#145964) 2025-06-02 09:48:42 +02:00
Allen Porter 4d833e9b1c Bump ical to 10.0.0 (#145954) 2025-06-02 09:47:05 +02:00
Robert Resch 6d827cd412 Deprecate hddtemp (#145850) 2025-06-02 09:45:14 +02:00
epenet ebfbea39ff Use async_load_fixture in twitch tests (#146016) 2025-06-02 09:27:53 +02:00
dependabot[bot] 89a40f1c48 Bump dawidd6/action-download-artifact from 9 to 10 (#146015) 2025-06-02 09:21:26 +02:00
epenet 664eb7af10 Use async_load_fixture in moehlenhoff_alpha2 tests (#146012) 2025-06-02 08:59:19 +02:00
epenet 33b99b6627 Use async_load_fixture in netatmo tests (#146013) 2025-06-02 08:59:11 +02:00
epenet 0cf2ee0bcb Remove unnecessary DOMAIN alias in tests (l-r) (#146009)
* Remove unnecessary DOMAIN alias in tests (l-r)

* Keep late import in lirc
2025-06-02 08:54:55 +02:00
hanwg 85a86c3f11 Add config flow for telegram bot integration (#144617)
* added config flow for telegram integration

* added chat id in config entry title and added config flow tests

* fix import issue when there are no notifiers in configuration.yaml

* Revert "fix import issue when there are no notifiers in configuration.yaml"

This reverts commit b5b83e2a9a5d8cd1572f3e8c36e360b0de80b58b.

* Revert "added chat id in config entry title and added config flow tests"

This reverts commit 30c2bb4ae4d850dae931a5f7e1525cf19e3be5d8.

* Revert "added config flow for telegram integration"

This reverts commit 1f44afcd45e3a017b8c5f681dc39a160617018ce.

* added config and subentry flows

* added options flow to configure webhooks

* refactor module setup so it only load once

* moved service registration from async_setup_entry to async_setup

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* import only last yaml config

* import only last yaml config

* reduced scope of try-block

* create issue when importing from yaml

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* handle options update by reloading telegram bot

* handle import errors for create issue

* include bot's platform when creating issues

* handle options reload without needing HA restart

* moved url and trusted_networks inputs from options to new config flow step

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* minor fixes

* refactor config flow

* moved constants to const.py

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* added options flow tests

* Update homeassistant/components/telegram_bot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/telegram_bot/config_flow.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* added reconfigure flow

* added reauth flow

* added tests for reconfigure flow

* added tests for reauth

* added tests for subentry flow

* added tests for user and webhooks flow with error scenarios

* added import flow tests

* handle webhook deregister exception

* added config entry id to all services

* fix leave chat bug

* Update homeassistant/components/telegram_bot/__init__.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* removed leave chat bug fixes

* Update homeassistant/components/telegram_bot/strings.json

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* handle other error types for import

* reuse translations

* added test for duplicated config entry for user step

* added tests

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-02 08:52:31 +02:00
epenet de4a5fa30b Remove unnecessary DOMAIN alias in tests (s-z) (#146010) 2025-06-02 08:48:37 +02:00
Marc Mueller 43ac550ca0 Update pydantic to 2.11.5 (#145985)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-06-02 08:48:22 +02:00
Marc Mueller c3c4d224b2 Update PyTurboJPEG to 1.8.0 (#145984)
Co-authored-by: Allen Porter <allen.porter@gmail.com>
2025-06-02 08:40:10 +02:00
Marc Mueller 6f865beacd Update attrs to 25.3.0 (#145977) 2025-06-02 07:58:35 +02:00
Marc Mueller de25195383 Update bcrypt to 4.3.0 (#145978) 2025-06-02 07:56:51 +02:00
Marc Mueller 0139d2cabf Update cryptography to 45.0.3 (#145979) 2025-06-02 07:53:58 +02:00
Marc Mueller 17542614b5 Update aiohttp-cors to 0.8.1 (#145976)
* Update aiohttp-cors to 0.8.1

* Fix mypy
2025-06-02 07:52:23 +02:00
Marc Mueller 885367e690 Update coverage to 7.8.2 (#145983) 2025-06-02 07:47:56 +02:00
Marc Mueller f8c44aad25 Update pytest-cov to 6.1.1 (#145989) 2025-06-02 07:34:11 +02:00
Marc Mueller 2323cc2869 Update numpy to 2.2.6 (#145981) 2025-06-01 21:23:30 -07:00
Marc Mueller 7f0249bbf7 Update pytest-timeout to 2.4.0 (#145990) 2025-06-02 06:17:39 +02:00
Marc Mueller 7a23b778a4 Update pytest-xdist to 3.7.0 (#145991) 2025-06-02 06:16:17 +02:00
Marc Mueller d910924032 Update syrupy to 4.9.1 (#145992) 2025-06-02 06:14:52 +02:00
Marc Mueller 0b93a8c2f2 Update types packages (#145993) 2025-06-02 06:13:08 +02:00
Marc Mueller 5e377b89fc Update pytest-asyncio to 1.0.0 (#145988)
* Update pytest-asyncio to 1.0.0

* Remove event_loop fixture uses
2025-06-02 06:12:22 +02:00
Marc Mueller dd85a1e5f0 Update mypy-dev to 1.17.0a2 (#146002)
* Update mypy-dev to 1.17.0a2

* Fix
2025-06-02 06:06:38 +02:00
Simone Chemelli b96a7aebcd Bump aioamazondevices to 3.0.4 (#145971) 2025-06-01 21:15:18 +02:00
Michael 3cfcf382da Bump aioimmich to 0.8.0 (#145908) 2025-06-01 21:14:19 +02:00
260 changed files with 6509 additions and 2811 deletions
+2 -2
View File
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v9
uses: dawidd6/action-download-artifact@v10
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@v9
uses: dawidd6/action-download-artifact@v10
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
+1 -1
View File
@@ -360,7 +360,7 @@ jobs:
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff --all-files --show-diff-on-failure
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
+2 -2
View File
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.18
uses: github/codeql-action/init@v3.28.19
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.18
uses: github/codeql-action/analyze@v3.28.19
with:
category: "/language:python"
+38 -4
View File
@@ -125,7 +125,7 @@ jobs:
core:
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
if: github.repository_owner == 'home-assistant'
if: false && github.repository_owner == 'home-assistant'
needs: init
runs-on: ubuntu-latest
strategy:
@@ -176,12 +176,26 @@ jobs:
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
if: github.repository_owner == 'home-assistant'
needs: init
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
abi: ["cp313"]
arch: ${{ fromJson(needs.init.outputs.architectures) }}
include:
- os: ubuntu-latest
arch: amd64
abi: cp313
- os: ubuntu-latest
arch: i386
abi: cp313
- os: ubuntu-24.04-arm
arch: aarch64
abi: cp313
- os: ubuntu-24.04
arch: armv7
abi: cp313
- os: ubuntu-latest
arch: armhf
abi: cp313
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
@@ -218,8 +232,28 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Create requirements file for custom build
run: |
touch requirements_custom.txt
echo "rpds_py==0.25.1" >> requirements_custom.txt
- name: Build wheels (custom)
uses: cdce8p/wheels@master
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements: "requirements_custom.txt"
verbose: true
- name: Build wheels
uses: home-assistant/wheels@2025.03.0
if: false
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
+3 -3
View File
@@ -1,8 +1,8 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.0
rev: v0.11.12
hooks:
- id: ruff
- id: ruff-check
args:
- --fix
- id: ruff-format
@@ -30,7 +30,7 @@ repos:
- --branch=master
- --branch=rc
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.35.1
rev: v1.37.1
hooks:
- id: yamllint
- repo: https://github.com/pre-commit/mirrors-prettier
+1 -1
View File
@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
"command": "pre-commit run ruff --all-files",
"command": "pre-commit run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
+1 -1
View File
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant
from .const import CONNECTION_TYPE, LOCAL
from .coordinator import AdaxCloudCoordinator, AdaxConfigEntry, AdaxLocalCoordinator
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AdaxConfigEntry) -> bool:
+24 -1
View File
@@ -41,7 +41,30 @@ class AdaxCloudCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch data from the Adax."""
rooms = await self.adax_data_handler.get_rooms() or []
try:
if hasattr(self.adax_data_handler, "fetch_rooms_info"):
rooms = await self.adax_data_handler.fetch_rooms_info() or []
_LOGGER.debug("fetch_rooms_info returned: %s", rooms)
else:
_LOGGER.debug("fetch_rooms_info method not available, using get_rooms")
rooms = []
if not rooms:
_LOGGER.debug(
"No rooms from fetch_rooms_info, trying get_rooms as fallback"
)
rooms = await self.adax_data_handler.get_rooms() or []
_LOGGER.debug("get_rooms fallback returned: %s", rooms)
if not rooms:
raise UpdateFailed("No rooms available from Adax API")
except OSError as e:
raise UpdateFailed(f"Error communicating with API: {e}") from e
for room in rooms:
room["energyWh"] = int(room.get("energyWh", 0))
return {r["id"]: r for r in rooms}
+77
View File
@@ -0,0 +1,77 @@
"""Support for Adax energy sensors."""
from __future__ import annotations
from typing import cast
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AdaxConfigEntry
from .const import CONNECTION_TYPE, DOMAIN, LOCAL
from .coordinator import AdaxCloudCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: AdaxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Adax energy sensors with config flow."""
if entry.data.get(CONNECTION_TYPE) != LOCAL:
cloud_coordinator = cast(AdaxCloudCoordinator, entry.runtime_data)
# Create individual energy sensors for each device
async_add_entities(
AdaxEnergySensor(cloud_coordinator, device_id)
for device_id in cloud_coordinator.data
)
class AdaxEnergySensor(CoordinatorEntity[AdaxCloudCoordinator], SensorEntity):
"""Representation of an Adax energy sensor."""
_attr_has_entity_name = True
_attr_translation_key = "energy"
_attr_device_class = SensorDeviceClass.ENERGY
_attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR
_attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
_attr_state_class = SensorStateClass.TOTAL_INCREASING
_attr_suggested_display_precision = 3
def __init__(
self,
coordinator: AdaxCloudCoordinator,
device_id: str,
) -> None:
"""Initialize the energy sensor."""
super().__init__(coordinator)
self._device_id = device_id
room = coordinator.data[device_id]
self._attr_unique_id = f"{room['homeId']}_{device_id}_energy"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
name=room["name"],
manufacturer="Adax",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
super().available and "energyWh" in self.coordinator.data[self._device_id]
)
@property
def native_value(self) -> int:
"""Return the native value of the sensor."""
return int(self.coordinator.data[self._device_id]["energyWh"])
@@ -0,0 +1,66 @@
"""Diagnostics support for Amazon Devices integration."""
from __future__ import annotations
from typing import Any
from aioamazondevices.api import AmazonDevice
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from .coordinator import AmazonConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
devices: list[dict[str, dict[str, Any]]] = [
build_device_data(device) for device in coordinator.data.values()
]
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"device_info": {
"last_update success": coordinator.last_update_success,
"last_exception": repr(coordinator.last_exception),
"devices": devices,
},
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
coordinator = entry.runtime_data
assert device_entry.serial_number
return build_device_data(coordinator.data[device_entry.serial_number])
def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"""Build device data for diagnostics."""
return {
"account name": device.account_name,
"capabilities": device.capabilities,
"device family": device.device_family,
"device type": device.device_type,
"device cluster members": device.device_cluster_members,
"online": device.online,
"serial number": device.serial_number,
"software version": device.software_version,
"do not disturb": device.do_not_disturb,
"response style": device.response_style,
"bluetooth state": device.bluetooth_state,
}
@@ -118,5 +118,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "bronze",
"requirements": ["aioamazondevices==2.1.1"]
"requirements": ["aioamazondevices==3.0.5"]
}
@@ -24,7 +24,7 @@ from homeassistant.components.recorder import (
get_instance as get_recorder_instance,
)
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION
from homeassistant.const import ATTR_DOMAIN, BASE_PLATFORMS, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
@@ -225,7 +225,8 @@ class Analytics:
LOGGER.error(err)
return
configuration_set = set(yaml_configuration)
configuration_set = _domains_from_yaml_config(yaml_configuration)
er_platforms = {
entity.platform
for entity in ent_reg.entities.values()
@@ -370,3 +371,13 @@ class Analytics:
for entry in entries
if entry.source != SOURCE_IGNORE and entry.disabled_by is None
)
def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
"""Extract domains from the YAML configuration."""
domains = set(yaml_configuration)
for platforms in conf_util.extract_platform_integrations(
yaml_configuration, BASE_PLATFORMS
).values():
domains.update(platforms)
return domains
@@ -6,6 +6,7 @@ from homeassistant.components.water_heater import (
STATE_ECO,
STATE_PERFORMANCE,
WaterHeaterEntity,
WaterHeaterEntityFeature,
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
@@ -32,6 +33,7 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity):
"""Representation of an ATAG water heater."""
_attr_operation_list = OPERATION_LIST
_attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@property
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.2",
"bluetooth-data-tools==1.28.1",
"dbus-fast==2.43.0",
"habluetooth==3.48.2"
"habluetooth==3.49.0"
]
}
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/camera",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.7.5"]
"requirements": ["PyTurboJPEG==1.8.0"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/compensation",
"iot_class": "calculated",
"quality_scale": "legacy",
"requirements": ["numpy==2.2.2"]
"requirements": ["numpy==2.2.6"]
}
@@ -1 +1,6 @@
"""The eddystone_temperature component."""
DOMAIN = "eddystone_temperature"
CONF_BEACONS = "beacons"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"
@@ -23,17 +23,18 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import CONF_BEACONS, CONF_INSTANCE, CONF_NAMESPACE, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONF_BEACONS = "beacons"
CONF_BT_DEVICE_ID = "bt_device_id"
CONF_INSTANCE = "instance"
CONF_NAMESPACE = "namespace"
BEACON_SCHEMA = vol.Schema(
{
@@ -58,6 +59,21 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Validate configuration, create devices and start monitoring thread."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Eddystone",
},
)
bt_device_id: int = config[CONF_BT_DEVICE_ID]
beacons: dict[str, dict[str, str]] = config[CONF_BEACONS]
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"]
}
@@ -17,9 +17,9 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==31.1.0",
"aioesphomeapi==32.0.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==2.15.1"
"bleak-esphome==2.16.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
@@ -71,6 +71,11 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
_attr_name = "DHW controller"
_attr_icon = "mdi:thermometer-lines"
_attr_operation_list = list(HA_STATE_TO_EVO)
_attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE
| WaterHeaterEntityFeature.ON_OFF
| WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_evo_device: evo.HotWater
@@ -91,9 +96,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
self._attr_precision = (
PRECISION_TENTHS if coordinator.client_v1 else PRECISION_WHOLE
)
self._attr_supported_features = (
WaterHeaterEntityFeature.AWAY_MODE | WaterHeaterEntityFeature.OPERATION_MODE
)
@property
def current_operation(self) -> str | None:
@@ -84,6 +84,7 @@ async def async_setup_entry(
name=f"Freebox {sensor_name}",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
)
for sensor_name in router.sensors_temperature
@@ -8,6 +8,6 @@
"integration_type": "system",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["go2rtc-client==0.1.3b0"],
"requirements": ["go2rtc-client==0.2.1"],
"single_config_entry": true
}
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==9.2.5"]
"requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"]
}
@@ -24,9 +24,11 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Google Mail platform."""
"""Set up the Google Mail integration."""
hass.data.setdefault(DOMAIN, {})[DATA_HASS_CONFIG] = config
await async_setup_services(hass)
return True
@@ -52,8 +54,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
await async_setup_services(hass)
return True
@@ -7,17 +7,26 @@ from google_photos_library_api.api import GooglePhotosLibraryApi
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import DOMAIN
from .coordinator import GooglePhotosConfigEntry, GooglePhotosUpdateCoordinator
from .services import async_register_services
__all__ = [
"DOMAIN",
]
__all__ = ["DOMAIN"]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Google Photos integration."""
async_register_services(hass)
return True
async def async_setup_entry(
@@ -48,8 +57,6 @@ async def async_setup_entry(
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
async_register_services(hass)
return True
@@ -152,11 +152,10 @@ def async_register_services(hass: HomeAssistant) -> None:
}
return None
if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
@@ -1 +1,3 @@
"""The hddtemp component."""
DOMAIN = "hddtemp"
+19 -1
View File
@@ -22,11 +22,14 @@ from homeassistant.const import (
CONF_PORT,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import DOMAIN
_LOGGER = logging.getLogger(__name__)
ATTR_DEVICE = "device"
@@ -56,6 +59,21 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the HDDTemp sensor."""
create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
breaks_in_ha_version="2025.12.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_system_packages_yaml_integration",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "hddtemp",
},
)
name = config.get(CONF_NAME)
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
@@ -73,7 +73,9 @@ async def async_setup_entry(
class HiveWaterHeater(HiveEntity, WaterHeaterEntity):
"""Hive Water Heater Device."""
_attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE
_attr_supported_features = (
WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.OPERATION_MODE
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_operation_list = SUPPORT_WATER_HEATER
@@ -83,3 +83,54 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=AUTH_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfigure flow."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input:
self.homee = Homee(
user_input[CONF_HOST],
reconfigure_entry.data[CONF_USERNAME],
reconfigure_entry.data[CONF_PASSWORD],
)
try:
await self.homee.get_access_token()
except HomeeConnectionFailedException:
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.hass.loop.create_task(self.homee.run())
await self.homee.wait_until_connected()
self.homee.disconnect()
await self.homee.wait_until_disconnected()
await self.async_set_unique_id(self.homee.settings.uid)
self._abort_if_unique_id_mismatch(reason="wrong_hub")
_LOGGER.debug("Updated homee entry with ID %s", self.homee.settings.uid)
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates=user_input
)
return self.async_show_form(
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): str
}
),
description_placeholders={
"name": reconfigure_entry.runtime_data.settings.uid
},
errors=errors,
)
+13 -1
View File
@@ -2,7 +2,9 @@
"config": {
"flow_title": "homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_hub": "Address belongs to a different homee."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -22,6 +24,16 @@
"username": "The username for your homee.",
"password": "The password for your homee."
}
},
"reconfigure": {
"title": "Reconfigure homee {name}",
"description": "Reconfigure the IP address of your homee.",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address of your homee."
}
}
}
},
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.14"],
"requirements": ["aiohomekit==3.2.15"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
@@ -21,7 +21,7 @@ from .const import (
HMIPC_NAME,
)
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .services import async_setup_services, async_unload_services
from .services import async_setup_services
CONFIG_SCHEMA = vol.Schema(
{
@@ -63,6 +63,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
)
await async_setup_services(hass)
return True
@@ -83,7 +85,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry)
if not await hap.async_setup():
return False
await async_setup_services(hass)
_async_remove_obsolete_entities(hass, entry, hap)
# Register on HA stop event to gracefully shutdown HomematicIP Cloud connection
@@ -115,8 +116,6 @@ async def async_unload_entry(
assert hap.reset_connection_listener is not None
hap.reset_connection_listener()
await async_unload_services(hass)
return await hap.async_reset()
@@ -123,32 +123,29 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema(
async def async_setup_services(hass: HomeAssistant) -> None:
"""Set up the HomematicIP Cloud services."""
if hass.services.async_services_for_domain(DOMAIN):
return
@verify_domain_control(hass, DOMAIN)
async def async_call_hmipc_service(service: ServiceCall) -> None:
"""Call correct HomematicIP Cloud service."""
service_name = service.service
if service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION:
await _async_activate_eco_mode_with_duration(hass, service)
await _async_activate_eco_mode_with_duration(service)
elif service_name == SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD:
await _async_activate_eco_mode_with_period(hass, service)
await _async_activate_eco_mode_with_period(service)
elif service_name == SERVICE_ACTIVATE_VACATION:
await _async_activate_vacation(hass, service)
await _async_activate_vacation(service)
elif service_name == SERVICE_DEACTIVATE_ECO_MODE:
await _async_deactivate_eco_mode(hass, service)
await _async_deactivate_eco_mode(service)
elif service_name == SERVICE_DEACTIVATE_VACATION:
await _async_deactivate_vacation(hass, service)
await _async_deactivate_vacation(service)
elif service_name == SERVICE_DUMP_HAP_CONFIG:
await _async_dump_hap_config(hass, service)
await _async_dump_hap_config(service)
elif service_name == SERVICE_RESET_ENERGY_COUNTER:
await _async_reset_energy_counter(hass, service)
await _async_reset_energy_counter(service)
elif service_name == SERVICE_SET_ACTIVE_CLIMATE_PROFILE:
await _set_active_climate_profile(hass, service)
await _set_active_climate_profile(service)
elif service_name == SERVICE_SET_HOME_COOLING_MODE:
await _async_set_home_cooling_mode(hass, service)
await _async_set_home_cooling_mode(service)
hass.services.async_register(
domain=DOMAIN,
@@ -217,90 +214,75 @@ async def async_setup_services(hass: HomeAssistant) -> None:
)
async def async_unload_services(hass: HomeAssistant):
"""Unload HomematicIP Cloud services."""
if hass.config_entries.async_loaded_entries(DOMAIN):
return
for hmipc_service in HMIPC_SERVICES:
hass.services.async_remove(domain=DOMAIN, service=hmipc_service)
async def _async_activate_eco_mode_with_duration(
hass: HomeAssistant, service: ServiceCall
) -> None:
async def _async_activate_eco_mode_with_duration(service: ServiceCall) -> None:
"""Service to activate eco mode with duration."""
duration = service.data[ATTR_DURATION]
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.activate_absence_with_duration_async(duration)
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.activate_absence_with_duration_async(duration)
async def _async_activate_eco_mode_with_period(
hass: HomeAssistant, service: ServiceCall
) -> None:
async def _async_activate_eco_mode_with_period(service: ServiceCall) -> None:
"""Service to activate eco mode with period."""
endtime = service.data[ATTR_ENDTIME]
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.activate_absence_with_period_async(endtime)
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.activate_absence_with_period_async(endtime)
async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
async def _async_activate_vacation(service: ServiceCall) -> None:
"""Service to activate vacation."""
endtime = service.data[ATTR_ENDTIME]
temperature = service.data[ATTR_TEMPERATURE]
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.activate_vacation_async(endtime, temperature)
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.activate_vacation_async(endtime, temperature)
async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None:
async def _async_deactivate_eco_mode(service: ServiceCall) -> None:
"""Service to deactivate eco mode."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.deactivate_absence_async()
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.deactivate_absence_async()
async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
async def _async_deactivate_vacation(service: ServiceCall) -> None:
"""Service to deactivate vacation."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.deactivate_vacation_async()
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.deactivate_vacation_async()
async def _set_active_climate_profile(
hass: HomeAssistant, service: ServiceCall
) -> None:
async def _set_active_climate_profile(service: ServiceCall) -> None:
"""Service to set the active climate profile."""
entity_id_list = service.data[ATTR_ENTITY_ID]
climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
if entity_id_list != "all":
for entity_id in entity_id_list:
group = entry.runtime_data.hmip_device_by_entity_id.get(entity_id)
@@ -312,16 +294,16 @@ async def _set_active_climate_profile(
await group.set_active_profile_async(climate_profile_index)
async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None:
async def _async_dump_hap_config(service: ServiceCall) -> None:
"""Service to dump the configuration of a Homematic IP Access Point."""
config_path: str = (
service.data.get(ATTR_CONFIG_OUTPUT_PATH) or hass.config.config_dir
service.data.get(ATTR_CONFIG_OUTPUT_PATH) or service.hass.config.config_dir
)
config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX]
anonymize = service.data[ATTR_ANONYMIZE]
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
hap_sgtin = entry.unique_id
assert hap_sgtin is not None
@@ -338,12 +320,12 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N
config_file.write_text(json_state, encoding="utf8")
async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall):
async def _async_reset_energy_counter(service: ServiceCall):
"""Service to reset the energy counter."""
entity_id_list = service.data[ATTR_ENTITY_ID]
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
if entity_id_list != "all":
for entity_id in entity_id_list:
device = entry.runtime_data.hmip_device_by_entity_id.get(entity_id)
@@ -355,16 +337,16 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall)
await device.reset_energy_counter_async()
async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall):
async def _async_set_home_cooling_mode(service: ServiceCall):
"""Service to set the cooling mode."""
cooling = service.data[ATTR_COOLING]
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
if home := _get_home(service.hass, hapid):
await home.set_cooling_async(cooling)
else:
entry: HomematicIPConfigEntry
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
for entry in service.hass.config_entries.async_loaded_entries(DOMAIN):
await entry.runtime_data.home.set_cooling_async(cooling)
@@ -12,6 +12,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==v8.3.2"],
"requirements": ["python-homewizard-energy==8.3.3"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}
+4 -4
View File
@@ -39,14 +39,14 @@ def setup_cors(app: Application, origins: list[str]) -> None:
cors = aiohttp_cors.setup(
app,
defaults={
host: aiohttp_cors.ResourceOptions(
host: aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
allow_headers=ALLOWED_CORS_HEADERS, allow_methods="*"
)
for host in origins
},
)
cors_added = set()
cors_added: set[str] = set()
def _allow_cors(
route: AbstractRoute | AbstractResource,
@@ -69,13 +69,13 @@ def setup_cors(app: Application, origins: list[str]) -> None:
if path_str in cors_added:
return
cors.add(route, config)
cors.add(route, config) # type: ignore[arg-type]
cors_added.add(path_str)
app[KEY_ALLOW_ALL_CORS] = lambda route: _allow_cors(
route,
{
"*": aiohttp_cors.ResourceOptions(
"*": aiohttp_cors.ResourceOptions( # type: ignore[no-untyped-call]
allow_headers=ALLOWED_CORS_HEADERS, allow_methods="*"
)
},
+14 -9
View File
@@ -5,13 +5,24 @@ from aiohue.util import normalize_bridge_id
from homeassistant.components import persistent_notification
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .bridge import HueBridge, HueConfigEntry
from .const import DOMAIN, SERVICE_HUE_ACTIVATE_SCENE
from .const import DOMAIN
from .migration import check_migration
from .services import async_register_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Hue integration."""
async_register_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool:
"""Set up a bridge from a config entry."""
@@ -23,9 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool:
if not await bridge.async_initialize_bridge():
return False
# register Hue domain services
async_register_services(hass)
api = bridge.api
# For backwards compat
@@ -106,7 +114,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: HueConfigEntry) -> bool:
"""Unload a config entry."""
unload_success = await entry.runtime_data.async_reset()
if not hass.config_entries.async_loaded_entries(DOMAIN):
hass.services.async_remove(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE)
return unload_success
return await entry.runtime_data.async_reset()
+14 -15
View File
@@ -59,21 +59,20 @@ def async_register_services(hass: HomeAssistant) -> None:
group_name,
)
if not hass.services.has_service(DOMAIN, SERVICE_HUE_ACTIVATE_SCENE):
# Register a local handler for scene activation
hass.services.async_register(
DOMAIN,
SERVICE_HUE_ACTIVATE_SCENE,
verify_domain_control(hass, DOMAIN)(hue_activate_scene),
schema=vol.Schema(
{
vol.Required(ATTR_GROUP_NAME): cv.string,
vol.Required(ATTR_SCENE_NAME): cv.string,
vol.Optional(ATTR_TRANSITION): cv.positive_int,
vol.Optional(ATTR_DYNAMIC): cv.boolean,
}
),
)
# Register a local handler for scene activation
hass.services.async_register(
DOMAIN,
SERVICE_HUE_ACTIVATE_SCENE,
verify_domain_control(hass, DOMAIN)(hue_activate_scene),
schema=vol.Schema(
{
vol.Required(ATTR_GROUP_NAME): cv.string,
vol.Required(ATTR_SCENE_NAME): cv.string,
vol.Optional(ATTR_TRANSITION): cv.positive_int,
vol.Optional(ATTR_DYNAMIC): cv.boolean,
}
),
)
async def hue_activate_scene_v1(
+13 -2
View File
@@ -6,19 +6,32 @@ from typing import Any
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from .account import IcloudAccount, IcloudConfigEntry
from .const import (
CONF_GPS_ACCURACY_THRESHOLD,
CONF_MAX_INTERVAL,
CONF_WITH_FAMILY,
DOMAIN,
PLATFORMS,
STORAGE_KEY,
STORAGE_VERSION,
)
from .services import register_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up iCloud integration."""
register_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bool:
"""Set up an iCloud account from a config entry."""
@@ -51,8 +64,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
register_services(hass)
return True
@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aioimmich"],
"quality_scale": "silver",
"requirements": ["aioimmich==0.7.0"]
"requirements": ["aioimmich==0.9.0"]
}
+34 -41
View File
@@ -133,10 +133,10 @@ class ImmichMediaSource(MediaSource):
identifier=f"{identifier.unique_id}|albums|{album.album_id}",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaClass.IMAGE,
title=album.name,
title=album.album_name,
can_play=False,
can_expand=True,
thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumbnail/image/jpg",
thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg",
)
for album in albums
]
@@ -153,47 +153,40 @@ class ImmichMediaSource(MediaSource):
except ImmichError:
return []
ret = [
BrowseMediaSource(
domain=DOMAIN,
identifier=(
f"{identifier.unique_id}|albums|"
f"{identifier.collection_id}|"
f"{asset.asset_id}|"
f"{asset.file_name}|"
f"{asset.mime_type}"
),
media_class=MediaClass.IMAGE,
media_content_type=asset.mime_type,
title=asset.file_name,
can_play=False,
can_expand=False,
thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{asset.mime_type}",
)
for asset in album_info.assets
if asset.mime_type.startswith("image/")
]
ret: list[BrowseMediaSource] = []
for asset in album_info.assets:
if not (mime_type := asset.original_mime_type) or not mime_type.startswith(
("image/", "video/")
):
continue
ret.extend(
BrowseMediaSource(
domain=DOMAIN,
identifier=(
f"{identifier.unique_id}|albums|"
f"{identifier.collection_id}|"
f"{asset.asset_id}|"
f"{asset.file_name}|"
f"{asset.mime_type}"
),
media_class=MediaClass.VIDEO,
media_content_type=asset.mime_type,
title=asset.file_name,
can_play=True,
can_expand=False,
thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/image/jpeg",
if mime_type.startswith("image/"):
media_class = MediaClass.IMAGE
can_play = False
thumb_mime_type = mime_type
else:
media_class = MediaClass.VIDEO
can_play = True
thumb_mime_type = "image/jpeg"
ret.append(
BrowseMediaSource(
domain=DOMAIN,
identifier=(
f"{identifier.unique_id}|albums|"
f"{identifier.collection_id}|"
f"{asset.asset_id}|"
f"{asset.original_file_name}|"
f"{mime_type}"
),
media_class=media_class,
media_content_type=mime_type,
title=asset.original_file_name,
can_play=can_play,
can_expand=False,
thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{thumb_mime_type}",
)
)
for asset in album_info.assets
if asset.mime_type.startswith("video/")
)
return ret
+1 -1
View File
@@ -25,9 +25,9 @@ from .const import (
DOMAIN,
INSTEON_PLATFORMS,
)
from .services import async_register_services
from .utils import (
add_insteon_events,
async_register_services,
get_device_platforms,
register_new_device_callback,
)
@@ -0,0 +1,291 @@
"""Utilities used by insteon component."""
from __future__ import annotations
import asyncio
import logging
from pyinsteon import devices
from pyinsteon.address import Address
from pyinsteon.managers.link_manager import (
async_enter_linking_mode,
async_enter_unlinking_mode,
)
from pyinsteon.managers.scene_manager import (
async_trigger_scene_off,
async_trigger_scene_on,
)
from pyinsteon.managers.x10_manager import (
async_x10_all_lights_off,
async_x10_all_lights_on,
async_x10_all_units_off,
)
from pyinsteon.x10_address import create as create_x10_address
from homeassistant.const import (
CONF_ADDRESS,
CONF_ENTITY_ID,
CONF_PLATFORM,
ENTITY_MATCH_ALL,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
dispatcher_send,
)
from .const import (
CONF_CAT,
CONF_DIM_STEPS,
CONF_HOUSECODE,
CONF_SUBCAT,
CONF_UNITCODE,
DOMAIN,
SIGNAL_ADD_DEFAULT_LINKS,
SIGNAL_ADD_DEVICE_OVERRIDE,
SIGNAL_ADD_X10_DEVICE,
SIGNAL_LOAD_ALDB,
SIGNAL_PRINT_ALDB,
SIGNAL_REMOVE_DEVICE_OVERRIDE,
SIGNAL_REMOVE_ENTITY,
SIGNAL_REMOVE_HA_DEVICE,
SIGNAL_REMOVE_INSTEON_DEVICE,
SIGNAL_REMOVE_X10_DEVICE,
SIGNAL_SAVE_DEVICES,
SRV_ADD_ALL_LINK,
SRV_ADD_DEFAULT_LINKS,
SRV_ALL_LINK_GROUP,
SRV_ALL_LINK_MODE,
SRV_CONTROLLER,
SRV_DEL_ALL_LINK,
SRV_HOUSECODE,
SRV_LOAD_ALDB,
SRV_LOAD_DB_RELOAD,
SRV_PRINT_ALDB,
SRV_PRINT_IM_ALDB,
SRV_SCENE_OFF,
SRV_SCENE_ON,
SRV_X10_ALL_LIGHTS_OFF,
SRV_X10_ALL_LIGHTS_ON,
SRV_X10_ALL_UNITS_OFF,
)
from .schemas import (
ADD_ALL_LINK_SCHEMA,
ADD_DEFAULT_LINKS_SCHEMA,
DEL_ALL_LINK_SCHEMA,
LOAD_ALDB_SCHEMA,
PRINT_ALDB_SCHEMA,
TRIGGER_SCENE_SCHEMA,
X10_HOUSECODE_SCHEMA,
)
from .utils import print_aldb_to_log
_LOGGER = logging.getLogger(__name__)
@callback
def async_register_services(hass: HomeAssistant) -> None: # noqa: C901
"""Register services used by insteon component."""
save_lock = asyncio.Lock()
async def async_srv_add_all_link(service: ServiceCall) -> None:
"""Add an INSTEON All-Link between two devices."""
group = service.data[SRV_ALL_LINK_GROUP]
mode = service.data[SRV_ALL_LINK_MODE]
link_mode = mode.lower() == SRV_CONTROLLER
await async_enter_linking_mode(link_mode, group)
async def async_srv_del_all_link(service: ServiceCall) -> None:
"""Delete an INSTEON All-Link between two devices."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_enter_unlinking_mode(group)
async def async_srv_load_aldb(service: ServiceCall) -> None:
"""Load the device All-Link database."""
entity_id = service.data[CONF_ENTITY_ID]
reload = service.data[SRV_LOAD_DB_RELOAD]
if entity_id.lower() == ENTITY_MATCH_ALL:
await async_srv_load_aldb_all(reload)
else:
signal = f"{entity_id}_{SIGNAL_LOAD_ALDB}"
async_dispatcher_send(hass, signal, reload)
async def async_srv_load_aldb_all(reload):
"""Load the All-Link database for all devices."""
# Cannot be done concurrently due to issues with the underlying protocol.
for address in devices:
device = devices[address]
if device != devices.modem and device.cat != 0x03:
await device.aldb.async_load(refresh=reload)
await async_srv_save_devices()
async def async_srv_save_devices():
"""Write the Insteon device configuration to file."""
async with save_lock:
_LOGGER.debug("Saving Insteon devices")
await devices.async_save(hass.config.config_dir)
def print_aldb(service: ServiceCall) -> None:
"""Print the All-Link Database for a device."""
# For now this sends logs to the log file.
# Future direction is to create an INSTEON control panel.
entity_id = service.data[CONF_ENTITY_ID]
signal = f"{entity_id}_{SIGNAL_PRINT_ALDB}"
dispatcher_send(hass, signal)
def print_im_aldb(service: ServiceCall) -> None:
"""Print the All-Link Database for a device."""
# For now this sends logs to the log file.
# Future direction is to create an INSTEON control panel.
print_aldb_to_log(devices.modem.aldb)
async def async_srv_x10_all_units_off(service: ServiceCall) -> None:
"""Send the X10 All Units Off command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_units_off(housecode)
async def async_srv_x10_all_lights_off(service: ServiceCall) -> None:
"""Send the X10 All Lights Off command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_lights_off(housecode)
async def async_srv_x10_all_lights_on(service: ServiceCall) -> None:
"""Send the X10 All Lights On command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_lights_on(housecode)
async def async_srv_scene_on(service: ServiceCall) -> None:
"""Trigger an INSTEON scene ON."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_trigger_scene_on(group)
async def async_srv_scene_off(service: ServiceCall) -> None:
"""Trigger an INSTEON scene ON."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_trigger_scene_off(group)
@callback
def async_add_default_links(service: ServiceCall) -> None:
"""Add the default All-Link entries to a device."""
entity_id = service.data[CONF_ENTITY_ID]
signal = f"{entity_id}_{SIGNAL_ADD_DEFAULT_LINKS}"
async_dispatcher_send(hass, signal)
async def async_add_device_override(override):
"""Remove an Insten device and associated entities."""
address = Address(override[CONF_ADDRESS])
await async_remove_ha_device(address)
devices.set_id(address, override[CONF_CAT], override[CONF_SUBCAT], 0)
await async_srv_save_devices()
async def async_remove_device_override(address):
"""Remove an Insten device and associated entities."""
address = Address(address)
await async_remove_ha_device(address)
devices.set_id(address, None, None, None)
await devices.async_identify_device(address)
await async_srv_save_devices()
@callback
def async_add_x10_device(x10_config):
"""Add X10 device."""
housecode = x10_config[CONF_HOUSECODE]
unitcode = x10_config[CONF_UNITCODE]
platform = x10_config[CONF_PLATFORM]
steps = x10_config.get(CONF_DIM_STEPS, 22)
x10_type = "on_off"
if platform == "light":
x10_type = "dimmable"
elif platform == "binary_sensor":
x10_type = "sensor"
_LOGGER.debug(
"Adding X10 device to Insteon: %s %d %s", housecode, unitcode, x10_type
)
# This must be run in the event loop
devices.add_x10_device(housecode, unitcode, x10_type, steps)
async def async_remove_x10_device(housecode, unitcode):
"""Remove an X10 device and associated entities."""
address = create_x10_address(housecode, unitcode)
devices.pop(address)
await async_remove_ha_device(address)
async def async_remove_ha_device(address: Address, remove_all_refs: bool = False):
"""Remove the device and all entities from hass."""
signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}"
async_dispatcher_send(hass, signal)
dev_registry = dr.async_get(hass)
device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))})
if device:
dev_registry.async_remove_device(device.id)
async def async_remove_insteon_device(
address: Address, remove_all_refs: bool = False
):
"""Remove the underlying Insteon device from the network."""
await devices.async_remove_device(
address=address, force=False, remove_all_refs=remove_all_refs
)
await async_srv_save_devices()
hass.services.async_register(
DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_DEL_ALL_LINK, async_srv_del_all_link, schema=DEL_ALL_LINK_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_LOAD_ALDB, async_srv_load_aldb, schema=LOAD_ALDB_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_PRINT_ALDB, print_aldb, schema=PRINT_ALDB_SCHEMA
)
hass.services.async_register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_UNITS_OFF,
async_srv_x10_all_units_off,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_LIGHTS_OFF,
async_srv_x10_all_lights_off,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_LIGHTS_ON,
async_srv_x10_all_lights_on,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN, SRV_SCENE_ON, async_srv_scene_on, schema=TRIGGER_SCENE_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_SCENE_OFF, async_srv_scene_off, schema=TRIGGER_SCENE_SCHEMA
)
hass.services.async_register(
DOMAIN,
SRV_ADD_DEFAULT_LINKS,
async_add_default_links,
schema=ADD_DEFAULT_LINKS_SCHEMA,
)
async_dispatcher_connect(hass, SIGNAL_SAVE_DEVICES, async_srv_save_devices)
async_dispatcher_connect(
hass, SIGNAL_ADD_DEVICE_OVERRIDE, async_add_device_override
)
async_dispatcher_connect(
hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, async_remove_device_override
)
async_dispatcher_connect(hass, SIGNAL_ADD_X10_DEVICE, async_add_x10_device)
async_dispatcher_connect(hass, SIGNAL_REMOVE_X10_DEVICE, async_remove_x10_device)
async_dispatcher_connect(hass, SIGNAL_REMOVE_HA_DEVICE, async_remove_ha_device)
async_dispatcher_connect(
hass, SIGNAL_REMOVE_INSTEON_DEVICE, async_remove_insteon_device
)
_LOGGER.debug("Insteon Services registered")
+4 -276
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import logging
from typing import TYPE_CHECKING, Any
@@ -12,90 +11,25 @@ from pyinsteon.address import Address
from pyinsteon.constants import ALDBStatus, DeviceAction
from pyinsteon.device_types.device_base import Device
from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event
from pyinsteon.managers.link_manager import (
async_enter_linking_mode,
async_enter_unlinking_mode,
)
from pyinsteon.managers.scene_manager import (
async_trigger_scene_off,
async_trigger_scene_on,
)
from pyinsteon.managers.x10_manager import (
async_x10_all_lights_off,
async_x10_all_lights_on,
async_x10_all_units_off,
)
from pyinsteon.x10_address import create as create_x10_address
from serial.tools import list_ports
from homeassistant.components import usb
from homeassistant.const import (
CONF_ADDRESS,
CONF_ENTITY_ID,
CONF_PLATFORM,
ENTITY_MATCH_ALL,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
dispatcher_send,
)
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CAT,
CONF_DIM_STEPS,
CONF_HOUSECODE,
CONF_SUBCAT,
CONF_UNITCODE,
DOMAIN,
EVENT_CONF_BUTTON,
EVENT_GROUP_OFF,
EVENT_GROUP_OFF_FAST,
EVENT_GROUP_ON,
EVENT_GROUP_ON_FAST,
SIGNAL_ADD_DEFAULT_LINKS,
SIGNAL_ADD_DEVICE_OVERRIDE,
SIGNAL_ADD_ENTITIES,
SIGNAL_ADD_X10_DEVICE,
SIGNAL_LOAD_ALDB,
SIGNAL_PRINT_ALDB,
SIGNAL_REMOVE_DEVICE_OVERRIDE,
SIGNAL_REMOVE_ENTITY,
SIGNAL_REMOVE_HA_DEVICE,
SIGNAL_REMOVE_INSTEON_DEVICE,
SIGNAL_REMOVE_X10_DEVICE,
SIGNAL_SAVE_DEVICES,
SRV_ADD_ALL_LINK,
SRV_ADD_DEFAULT_LINKS,
SRV_ALL_LINK_GROUP,
SRV_ALL_LINK_MODE,
SRV_CONTROLLER,
SRV_DEL_ALL_LINK,
SRV_HOUSECODE,
SRV_LOAD_ALDB,
SRV_LOAD_DB_RELOAD,
SRV_PRINT_ALDB,
SRV_PRINT_IM_ALDB,
SRV_SCENE_OFF,
SRV_SCENE_ON,
SRV_X10_ALL_LIGHTS_OFF,
SRV_X10_ALL_LIGHTS_ON,
SRV_X10_ALL_UNITS_OFF,
)
from .ipdb import get_device_platform_groups, get_device_platforms
from .schemas import (
ADD_ALL_LINK_SCHEMA,
ADD_DEFAULT_LINKS_SCHEMA,
DEL_ALL_LINK_SCHEMA,
LOAD_ALDB_SCHEMA,
PRINT_ALDB_SCHEMA,
TRIGGER_SCENE_SCHEMA,
X10_HOUSECODE_SCHEMA,
)
if TYPE_CHECKING:
from .entity import InsteonEntity
@@ -154,7 +88,7 @@ def add_insteon_events(hass: HomeAssistant, device: Device) -> None:
_register_event(event, async_fire_insteon_event)
def register_new_device_callback(hass):
def register_new_device_callback(hass: HomeAssistant) -> None:
"""Register callback for new Insteon device."""
@callback
@@ -180,212 +114,6 @@ def register_new_device_callback(hass):
devices.subscribe(async_new_insteon_device, force_strong_ref=True)
@callback
def async_register_services(hass): # noqa: C901
"""Register services used by insteon component."""
save_lock = asyncio.Lock()
async def async_srv_add_all_link(service: ServiceCall) -> None:
"""Add an INSTEON All-Link between two devices."""
group = service.data[SRV_ALL_LINK_GROUP]
mode = service.data[SRV_ALL_LINK_MODE]
link_mode = mode.lower() == SRV_CONTROLLER
await async_enter_linking_mode(link_mode, group)
async def async_srv_del_all_link(service: ServiceCall) -> None:
"""Delete an INSTEON All-Link between two devices."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_enter_unlinking_mode(group)
async def async_srv_load_aldb(service: ServiceCall) -> None:
"""Load the device All-Link database."""
entity_id = service.data[CONF_ENTITY_ID]
reload = service.data[SRV_LOAD_DB_RELOAD]
if entity_id.lower() == ENTITY_MATCH_ALL:
await async_srv_load_aldb_all(reload)
else:
signal = f"{entity_id}_{SIGNAL_LOAD_ALDB}"
async_dispatcher_send(hass, signal, reload)
async def async_srv_load_aldb_all(reload):
"""Load the All-Link database for all devices."""
# Cannot be done concurrently due to issues with the underlying protocol.
for address in devices:
device = devices[address]
if device != devices.modem and device.cat != 0x03:
await device.aldb.async_load(refresh=reload)
await async_srv_save_devices()
async def async_srv_save_devices():
"""Write the Insteon device configuration to file."""
async with save_lock:
_LOGGER.debug("Saving Insteon devices")
await devices.async_save(hass.config.config_dir)
def print_aldb(service: ServiceCall) -> None:
"""Print the All-Link Database for a device."""
# For now this sends logs to the log file.
# Future direction is to create an INSTEON control panel.
entity_id = service.data[CONF_ENTITY_ID]
signal = f"{entity_id}_{SIGNAL_PRINT_ALDB}"
dispatcher_send(hass, signal)
def print_im_aldb(service: ServiceCall) -> None:
"""Print the All-Link Database for a device."""
# For now this sends logs to the log file.
# Future direction is to create an INSTEON control panel.
print_aldb_to_log(devices.modem.aldb)
async def async_srv_x10_all_units_off(service: ServiceCall) -> None:
"""Send the X10 All Units Off command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_units_off(housecode)
async def async_srv_x10_all_lights_off(service: ServiceCall) -> None:
"""Send the X10 All Lights Off command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_lights_off(housecode)
async def async_srv_x10_all_lights_on(service: ServiceCall) -> None:
"""Send the X10 All Lights On command."""
housecode = service.data.get(SRV_HOUSECODE)
await async_x10_all_lights_on(housecode)
async def async_srv_scene_on(service: ServiceCall) -> None:
"""Trigger an INSTEON scene ON."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_trigger_scene_on(group)
async def async_srv_scene_off(service: ServiceCall) -> None:
"""Trigger an INSTEON scene ON."""
group = service.data.get(SRV_ALL_LINK_GROUP)
await async_trigger_scene_off(group)
@callback
def async_add_default_links(service: ServiceCall) -> None:
"""Add the default All-Link entries to a device."""
entity_id = service.data[CONF_ENTITY_ID]
signal = f"{entity_id}_{SIGNAL_ADD_DEFAULT_LINKS}"
async_dispatcher_send(hass, signal)
async def async_add_device_override(override):
"""Remove an Insten device and associated entities."""
address = Address(override[CONF_ADDRESS])
await async_remove_ha_device(address)
devices.set_id(address, override[CONF_CAT], override[CONF_SUBCAT], 0)
await async_srv_save_devices()
async def async_remove_device_override(address):
"""Remove an Insten device and associated entities."""
address = Address(address)
await async_remove_ha_device(address)
devices.set_id(address, None, None, None)
await devices.async_identify_device(address)
await async_srv_save_devices()
@callback
def async_add_x10_device(x10_config):
"""Add X10 device."""
housecode = x10_config[CONF_HOUSECODE]
unitcode = x10_config[CONF_UNITCODE]
platform = x10_config[CONF_PLATFORM]
steps = x10_config.get(CONF_DIM_STEPS, 22)
x10_type = "on_off"
if platform == "light":
x10_type = "dimmable"
elif platform == "binary_sensor":
x10_type = "sensor"
_LOGGER.debug(
"Adding X10 device to Insteon: %s %d %s", housecode, unitcode, x10_type
)
# This must be run in the event loop
devices.add_x10_device(housecode, unitcode, x10_type, steps)
async def async_remove_x10_device(housecode, unitcode):
"""Remove an X10 device and associated entities."""
address = create_x10_address(housecode, unitcode)
devices.pop(address)
await async_remove_ha_device(address)
async def async_remove_ha_device(address: Address, remove_all_refs: bool = False):
"""Remove the device and all entities from hass."""
signal = f"{address.id}_{SIGNAL_REMOVE_ENTITY}"
async_dispatcher_send(hass, signal)
dev_registry = dr.async_get(hass)
device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))})
if device:
dev_registry.async_remove_device(device.id)
async def async_remove_insteon_device(
address: Address, remove_all_refs: bool = False
):
"""Remove the underlying Insteon device from the network."""
await devices.async_remove_device(
address=address, force=False, remove_all_refs=remove_all_refs
)
await async_srv_save_devices()
hass.services.async_register(
DOMAIN, SRV_ADD_ALL_LINK, async_srv_add_all_link, schema=ADD_ALL_LINK_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_DEL_ALL_LINK, async_srv_del_all_link, schema=DEL_ALL_LINK_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_LOAD_ALDB, async_srv_load_aldb, schema=LOAD_ALDB_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_PRINT_ALDB, print_aldb, schema=PRINT_ALDB_SCHEMA
)
hass.services.async_register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_UNITS_OFF,
async_srv_x10_all_units_off,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_LIGHTS_OFF,
async_srv_x10_all_lights_off,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SRV_X10_ALL_LIGHTS_ON,
async_srv_x10_all_lights_on,
schema=X10_HOUSECODE_SCHEMA,
)
hass.services.async_register(
DOMAIN, SRV_SCENE_ON, async_srv_scene_on, schema=TRIGGER_SCENE_SCHEMA
)
hass.services.async_register(
DOMAIN, SRV_SCENE_OFF, async_srv_scene_off, schema=TRIGGER_SCENE_SCHEMA
)
hass.services.async_register(
DOMAIN,
SRV_ADD_DEFAULT_LINKS,
async_add_default_links,
schema=ADD_DEFAULT_LINKS_SCHEMA,
)
async_dispatcher_connect(hass, SIGNAL_SAVE_DEVICES, async_srv_save_devices)
async_dispatcher_connect(
hass, SIGNAL_ADD_DEVICE_OVERRIDE, async_add_device_override
)
async_dispatcher_connect(
hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, async_remove_device_override
)
async_dispatcher_connect(hass, SIGNAL_ADD_X10_DEVICE, async_add_x10_device)
async_dispatcher_connect(hass, SIGNAL_REMOVE_X10_DEVICE, async_remove_x10_device)
async_dispatcher_connect(hass, SIGNAL_REMOVE_HA_DEVICE, async_remove_ha_device)
async_dispatcher_connect(
hass, SIGNAL_REMOVE_INSTEON_DEVICE, async_remove_insteon_device
)
_LOGGER.debug("Insteon Services registered")
def print_aldb_to_log(aldb):
"""Print the All-Link Database to the log file."""
logger = logging.getLogger(f"{__name__}.links")
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyiqvia"],
"requirements": ["numpy==2.2.2", "pyiqvia==2022.04.0"]
"requirements": ["numpy==2.2.6", "pyiqvia==2022.04.0"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyiskra"],
"requirements": ["pyiskra==0.1.19"]
"requirements": ["pyiskra==0.1.21"]
}
+10 -7
View File
@@ -26,6 +26,7 @@ from homeassistant.helpers import (
device_registry as dr,
)
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.typing import ConfigType
from .const import (
_LOGGER,
@@ -46,7 +47,7 @@ from .const import (
)
from .helpers import _categorize_nodes, _categorize_programs
from .models import IsyConfigEntry, IsyData
from .services import async_setup_services, async_unload_services
from .services import async_setup_services
from .util import _async_cleanup_registry_entries
CONFIG_SCHEMA = vol.Schema(
@@ -55,6 +56,14 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the ISY 994 integration."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
"""Set up the ISY 994 integration."""
isy_config = entry.data
@@ -167,9 +176,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update)
)
# Register Integration-wide Services:
async_setup_services(hass)
return True
@@ -221,9 +227,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
entry.runtime_data.root.websocket.stop()
if not hass.config_entries.async_loaded_entries(DOMAIN):
async_unload_services(hass)
return unload_ok
@@ -137,10 +137,6 @@ def async_get_entities(hass: HomeAssistant) -> dict[str, Entity]:
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Create and register services for the ISY integration."""
existing_services = hass.services.async_services_for_domain(DOMAIN)
if existing_services and SERVICE_SEND_PROGRAM_COMMAND in existing_services:
# Integration-level services have already been added. Return.
return
async def async_send_program_command_service_handler(service: ServiceCall) -> None:
"""Handle a send program command service call."""
@@ -230,18 +226,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
schema=cv.make_entity_service_schema(SERVICE_RENAME_NODE_SCHEMA),
service_func=_async_rename_node,
)
@callback
def async_unload_services(hass: HomeAssistant) -> None:
"""Unload services for the ISY integration."""
existing_services = hass.services.async_services_for_domain(DOMAIN)
if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services:
return
_LOGGER.debug("Unloading ISY994 Services")
hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND)
hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_RAW_NODE_COMMAND)
hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_NODE_COMMAND)
hass.services.async_remove(domain=DOMAIN, service=SERVICE_GET_ZWAVE_PARAMETER)
hass.services.async_remove(domain=DOMAIN, service=SERVICE_SET_ZWAVE_PARAMETER)
@@ -30,7 +30,7 @@ from .const import (
DOMAIN,
)
from .entity import JewishCalendarConfigEntry, JewishCalendarData
from .service import async_setup_services
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -99,7 +99,7 @@ rules:
status: exempt
comment: |
Since all entities are configured manually, names are user-defined.
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
+14 -4
View File
@@ -87,7 +87,9 @@ def get_knx_module(hass: HomeAssistant) -> KNXModule:
try:
return hass.data[KNX_MODULE_KEY]
except KeyError as err:
raise HomeAssistantError("KNX entry not loaded") from err
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="integration_not_loaded"
) from err
SERVICE_KNX_EVENT_REGISTER_SCHEMA = vol.Schema(
@@ -166,7 +168,11 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
removed_exposure = knx_module.service_exposures.pop(group_address)
except KeyError as err:
raise ServiceValidationError(
f"Could not find exposure for '{group_address}' to remove."
translation_domain=DOMAIN,
translation_key="service_exposure_remove_not_found",
translation_placeholders={
"group_address": group_address,
},
) from err
removed_exposure.async_remove()
@@ -234,13 +240,17 @@ async def service_send_to_knx_bus(call: ServiceCall) -> None:
transcoder = DPTBase.parse_transcoder(attr_type)
if transcoder is None:
raise ServiceValidationError(
f"Invalid type for knx.send service: {attr_type}"
translation_domain=DOMAIN,
translation_key="service_send_invalid_type",
translation_placeholders={"type": attr_type},
)
try:
payload = transcoder.to_knx(attr_payload)
except ConversionError as err:
raise ServiceValidationError(
f"Invalid payload for knx.send service: {err}"
translation_domain=DOMAIN,
translation_key="service_send_invalid_payload",
translation_placeholders={"error": str(err)},
) from err
elif isinstance(attr_payload, int):
payload = DPTBinary(attr_payload)
+15 -1
View File
@@ -131,7 +131,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal numbers expected.",
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.",
"invalid_individual_address": "Value does not match pattern for KNX individual address.\n'area.line.device'",
"invalid_ip_address": "Invalid IPv4 address.",
"keyfile_invalid_signature": "The password to decrypt the `.knxkeys` file is wrong.",
@@ -143,6 +143,20 @@
"unsupported_tunnel_type": "Selected tunneling type not supported by gateway."
}
},
"exceptions": {
"integration_not_loaded": {
"message": "KNX integration is not loaded."
},
"service_exposure_remove_not_found": {
"message": "Could not find exposure for `{group_address}` to remove."
},
"service_send_invalid_payload": {
"message": "Invalid payload for `knx.send` service. {error}"
},
"service_send_invalid_type": {
"message": "Invalid type for `knx.send` service: {type}"
}
},
"options": {
"step": {
"init": {
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==9.2.5"]
"requirements": ["ical==10.0.0"]
}
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==9.2.5"]
"requirements": ["ical==10.0.0"]
}
+2 -2
View File
@@ -162,7 +162,7 @@ class MatterLight(MatterEntity, LightEntity):
assert level_control is not None
level = round( # type: ignore[unreachable]
level = round(
renormalize(
brightness,
(0, 255),
@@ -249,7 +249,7 @@ class MatterLight(MatterEntity, LightEntity):
# We should not get here if brightness is not supported.
assert level_control is not None
LOGGER.debug( # type: ignore[unreachable]
LOGGER.debug(
"Got brightness %s for %s",
level_control.currentLevel,
self.entity_id,
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],
"requirements": ["pymodbus==3.8.3"]
"requirements": ["pymodbus==3.9.2"]
}
@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"requirements": ["pyatmo==9.2.0"]
"requirements": ["pyatmo==9.2.1"]
}
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/nextbus",
"iot_class": "cloud_polling",
"loggers": ["py_nextbus"],
"requirements": ["py-nextbusnext==2.1.2"]
"requirements": ["py-nextbusnext==2.2.0"]
}
@@ -7,8 +7,6 @@ from pyrail.models import StationDetails
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import Platform
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
BooleanSelector,
@@ -22,7 +20,6 @@ from .const import (
CONF_EXCLUDE_VIAS,
CONF_SHOW_ON_MAP,
CONF_STATION_FROM,
CONF_STATION_LIVE,
CONF_STATION_TO,
DOMAIN,
)
@@ -115,68 +112,6 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Import configuration from yaml."""
try:
self.stations = await self._fetch_stations()
except CannotConnect:
return self.async_abort(reason="api_unavailable")
station_from = None
station_to = None
station_live = None
for station in self.stations:
if user_input[CONF_STATION_FROM] in (
station.standard_name,
station.name,
):
station_from = station
if user_input[CONF_STATION_TO] in (
station.standard_name,
station.name,
):
station_to = station
if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in (
station.standard_name,
station.name,
):
station_live = station
if station_from is None or station_to is None:
return self.async_abort(reason="invalid_station")
if station_from == station_to:
return self.async_abort(reason="same_station")
# config flow uses id and not the standard name
user_input[CONF_STATION_FROM] = station_from.id
user_input[CONF_STATION_TO] = station_to.id
if station_live:
user_input[CONF_STATION_LIVE] = station_live.id
entity_registry = er.async_get(self.hass)
prefix = "live"
vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else ""
if entity_id := entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}",
):
new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}"
entity_registry.async_update_entity(
entity_id, new_unique_id=new_unique_id
)
if entity_id := entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}",
):
new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}"
entity_registry.async_update_entity(
entity_id, new_unique_id=new_unique_id
)
return await self.async_step_user(user_input)
class CannotConnect(Exception):
"""Error to indicate we cannot connect to NMBS."""
+11 -103
View File
@@ -8,30 +8,19 @@ from typing import Any
from pyrail import iRail
from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_NAME,
CONF_PLATFORM,
CONF_SHOW_ON_MAP,
UnitOfTime,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import ( # noqa: F401
@@ -47,22 +36,9 @@ from .const import ( # noqa: F401
_LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "NMBS"
DEFAULT_ICON = "mdi:train"
DEFAULT_ICON_ALERT = "mdi:alert-octagon"
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_STATION_FROM): cv.string,
vol.Required(CONF_STATION_TO): cv.string,
vol.Optional(CONF_STATION_LIVE): cv.string,
vol.Optional(CONF_EXCLUDE_VIAS, default=False): cv.boolean,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
}
)
def get_time_until(departure_time: datetime | None = None):
"""Calculate the time between now and a train's departure time."""
@@ -85,71 +61,6 @@ def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0)
return duration_time + get_delay_in_minutes(delay)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the NMBS sensor with iRail API."""
if config[CONF_PLATFORM] == DOMAIN:
if CONF_SHOW_ON_MAP not in config:
config[CONF_SHOW_ON_MAP] = False
if CONF_EXCLUDE_VIAS not in config:
config[CONF_EXCLUDE_VIAS] = False
station_types = [CONF_STATION_FROM, CONF_STATION_TO, CONF_STATION_LIVE]
for station_type in station_types:
station = (
find_station_by_name(hass, config[station_type])
if station_type in config
else None
)
if station is None and station_type in config:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_station_not_found",
breaks_in_ha_version="2025.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_station_not_found",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "NMBS",
"station_name": config[station_type],
"url": "/config/integrations/dashboard/add?domain=nmbs",
},
)
return
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
)
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.7.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "NMBS",
},
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@@ -336,7 +247,6 @@ class NMBSSensor(SensorEntity):
delay = get_delay_in_minutes(self._attrs.departure.delay)
departure = get_time_until(self._attrs.departure.time)
canceled = self._attrs.departure.canceled
attrs = {
"destination": self._attrs.departure.station,
@@ -346,14 +256,13 @@ class NMBSSensor(SensorEntity):
"vehicle_id": self._attrs.departure.vehicle,
}
if not canceled:
attrs["departure"] = f"In {departure} minutes"
attrs["departure_minutes"] = departure
attrs["canceled"] = False
else:
attrs["canceled"] = self._attrs.departure.canceled
if attrs["canceled"]:
attrs["departure"] = None
attrs["departure_minutes"] = None
attrs["canceled"] = True
else:
attrs["departure"] = f"In {departure} minutes"
attrs["departure_minutes"] = departure
if self._show_on_map and self.station_coordinates:
attrs[ATTR_LATITUDE] = self.station_coordinates[0]
@@ -369,9 +278,8 @@ class NMBSSensor(SensorEntity):
via.timebetween
) + get_delay_in_minutes(via.departure.delay)
if delay > 0:
attrs["delay"] = f"{delay} minutes"
attrs["delay_minutes"] = delay
attrs["delay"] = f"{delay} minutes"
attrs["delay_minutes"] = delay
return attrs
@@ -25,11 +25,5 @@
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_station_not_found": {
"title": "The {integration_title} YAML configuration import failed",
"description": "Configuring {integration_title} using YAML is being removed but there was a problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}
+11 -43
View File
@@ -1,30 +1,25 @@
"""The NZBGet integration."""
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_SPEED,
DATA_COORDINATOR,
DATA_UNDO_UPDATE_LISTENER,
DEFAULT_SPEED_LIMIT,
DOMAIN,
SERVICE_PAUSE,
SERVICE_RESUME,
SERVICE_SET_SPEED,
)
from .const import DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, DOMAIN
from .coordinator import NZBGetDataUpdateCoordinator
from .services import async_register_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH]
SPEED_LIMIT_SCHEMA = vol.Schema(
{vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.positive_int}
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up NZBGet integration."""
async_register_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -44,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_async_register_services(hass, coordinator)
return True
@@ -60,31 +53,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
def _async_register_services(
hass: HomeAssistant,
coordinator: NZBGetDataUpdateCoordinator,
) -> None:
"""Register integration-level services."""
def pause(call: ServiceCall) -> None:
"""Service call to pause downloads in NZBGet."""
coordinator.nzbget.pausedownload()
def resume(call: ServiceCall) -> None:
"""Service call to resume downloads in NZBGet."""
coordinator.nzbget.resumedownload()
def set_speed(call: ServiceCall) -> None:
"""Service call to rate limit speeds in NZBGet."""
coordinator.nzbget.rate(call.data[ATTR_SPEED])
hass.services.async_register(DOMAIN, SERVICE_PAUSE, pause, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_RESUME, resume, schema=vol.Schema({}))
hass.services.async_register(
DOMAIN, SERVICE_SET_SPEED, set_speed, schema=SPEED_LIMIT_SCHEMA
)
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -0,0 +1,58 @@
"""The NZBGet integration."""
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import (
ATTR_SPEED,
DATA_COORDINATOR,
DEFAULT_SPEED_LIMIT,
DOMAIN,
SERVICE_PAUSE,
SERVICE_RESUME,
SERVICE_SET_SPEED,
)
from .coordinator import NZBGetDataUpdateCoordinator
SPEED_LIMIT_SCHEMA = vol.Schema(
{vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.positive_int}
)
def _get_coordinator(call: ServiceCall) -> NZBGetDataUpdateCoordinator:
"""Service call to pause downloads in NZBGet."""
entries = call.hass.config_entries.async_loaded_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_config_entry",
)
return call.hass.data[DOMAIN][entries[0].entry_id][DATA_COORDINATOR]
def pause(call: ServiceCall) -> None:
"""Service call to pause downloads in NZBGet."""
_get_coordinator(call).nzbget.pausedownload()
def resume(call: ServiceCall) -> None:
"""Service call to resume downloads in NZBGet."""
_get_coordinator(call).nzbget.resumedownload()
def set_speed(call: ServiceCall) -> None:
"""Service call to rate limit speeds in NZBGet."""
_get_coordinator(call).nzbget.rate(call.data[ATTR_SPEED])
def async_register_services(hass: HomeAssistant) -> None:
"""Register integration-level services."""
hass.services.async_register(DOMAIN, SERVICE_PAUSE, pause, schema=vol.Schema({}))
hass.services.async_register(DOMAIN, SERVICE_RESUME, resume, schema=vol.Schema({}))
hass.services.async_register(
DOMAIN, SERVICE_SET_SPEED, set_speed, schema=SPEED_LIMIT_SCHEMA
)
@@ -64,6 +64,11 @@
}
}
},
"exceptions": {
"invalid_config_entry": {
"message": "Config entry not found or not loaded!"
}
},
"services": {
"pause": {
"name": "[%key:common::action::pause%]",
@@ -21,6 +21,7 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_TIMEOUT,
DOMAIN,
)
@@ -33,6 +34,7 @@ __all__ = [
"CONF_MODEL",
"CONF_NUM_CTX",
"CONF_PROMPT",
"CONF_THINK",
"CONF_URL",
"DOMAIN",
]
@@ -22,6 +22,7 @@ from homeassistant.const import CONF_LLM_HASS_API, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
BooleanSelector,
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
@@ -41,10 +42,12 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_KEEP_ALIVE,
DEFAULT_MAX_HISTORY,
DEFAULT_MODEL,
DEFAULT_NUM_CTX,
DEFAULT_THINK,
DEFAULT_TIMEOUT,
DOMAIN,
MAX_NUM_CTX,
@@ -280,6 +283,12 @@ def ollama_config_option_schema(
min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX
)
),
vol.Optional(
CONF_THINK,
description={
"suggested_value": options.get("think", DEFAULT_THINK),
},
): BooleanSelector(),
}
+2
View File
@@ -4,6 +4,7 @@ DOMAIN = "ollama"
CONF_MODEL = "model"
CONF_PROMPT = "prompt"
CONF_THINK = "think"
CONF_KEEP_ALIVE = "keep_alive"
DEFAULT_KEEP_ALIVE = -1 # seconds. -1 = indefinite, 0 = never
@@ -15,6 +16,7 @@ CONF_NUM_CTX = "num_ctx"
DEFAULT_NUM_CTX = 8192
MIN_NUM_CTX = 2048
MAX_NUM_CTX = 131072
DEFAULT_THINK = False
CONF_MAX_HISTORY = "max_history"
DEFAULT_MAX_HISTORY = 20
@@ -24,6 +24,7 @@ from .const import (
CONF_MODEL,
CONF_NUM_CTX,
CONF_PROMPT,
CONF_THINK,
DEFAULT_KEEP_ALIVE,
DEFAULT_MAX_HISTORY,
DEFAULT_NUM_CTX,
@@ -256,6 +257,7 @@ class OllamaConversationEntity(
# keep_alive requires specifying unit. In this case, seconds
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
think=settings.get(CONF_THINK),
)
except (ollama.RequestError, ollama.ResponseError) as err:
_LOGGER.error("Unexpected error talking to Ollama server: %s", err)
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/ollama",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["ollama==0.4.7"]
"requirements": ["ollama==0.5.1"]
}
+4 -2
View File
@@ -30,12 +30,14 @@
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"max_history": "Max history messages",
"num_ctx": "Context window size",
"keep_alive": "Keep alive"
"keep_alive": "Keep alive",
"think": "Think before responding"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
"keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.",
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities."
"num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.",
"think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency."
}
}
}
@@ -121,11 +121,10 @@ def async_register_services(hass: HomeAssistant) -> None:
return {"files": [asdict(item_result) for item_result in upload_results]}
return None
if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
hass.services.async_register(
DOMAIN,
UPLOAD_SERVICE,
async_handle_upload,
schema=UPLOAD_SERVICE_SCHEMA,
supports_response=SupportsResponse.OPTIONAL,
)
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.17.1"],
"requirements": ["pyoverkiz==1.17.2"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
+11 -3
View File
@@ -5,14 +5,25 @@ from python_picnic_api2 import PicnicAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_API, CONF_COORDINATOR, DOMAIN
from .coordinator import PicnicUpdateCoordinator
from .services import async_register_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.TODO]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Picnic integration."""
await async_register_services(hass)
return True
def create_picnic_client(entry: ConfigEntry):
"""Create an instance of the PicnicAPI client."""
return PicnicAPI(
@@ -37,9 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register the services
await async_register_services(hass)
return True
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/picnic",
"iot_class": "cloud_polling",
"loggers": ["python_picnic_api2"],
"requirements": ["python-picnic-api2==1.2.4"]
"requirements": ["python-picnic-api2==1.3.1"]
}
@@ -29,9 +29,6 @@ class PicnicServiceException(Exception):
async def async_register_services(hass: HomeAssistant) -> None:
"""Register services for the Picnic integration, if not registered yet."""
if hass.services.has_service(DOMAIN, SERVICE_ADD_PRODUCT_TO_CART):
return
async def async_add_product_service(call: ServiceCall):
api_client = await get_api_client(hass, call.data[ATTR_CONFIG_ENTRY_ID])
await handle_add_product(hass, api_client, call)
+1 -5
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import cast
from homeassistant.const import ATTR_SW_VERSION
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -40,7 +38,5 @@ class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator[T]]):
name=self.coordinator.config_entry.title,
)
if isinstance(self.coordinator, StatusDataUpdateCoordinator):
device_info[ATTR_SW_VERSION] = cast(
StatusDataUpdateCoordinator, self.coordinator
).data.version
device_info[ATTR_SW_VERSION] = self.coordinator.data.version
return device_info
@@ -13,5 +13,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiokem"],
"quality_scale": "silver",
"requirements": ["aiokem==0.5.12"]
"requirements": ["aiokem==1.0.1"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==9.2.5"]
"requirements": ["ical==10.0.0"]
}
@@ -150,6 +150,10 @@ async def async_setup_entry(
if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
# Their are new cameras/chimes connected, reload to add them.
_LOGGER.debug(
"Reloading Reolink %s to add new device (capabilities)",
host.api.nvr_name,
)
hass.async_create_task(
hass.config_entries.async_reload(config_entry.entry_id)
)
@@ -194,6 +194,13 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
)
raise AbortFlow("already_configured")
if existing_entry and existing_entry.data[CONF_HOST] != discovery_info.ip:
_LOGGER.debug(
"Reolink DHCP reported new IP '%s', updating from old IP '%s'",
discovery_info.ip,
existing_entry.data[CONF_HOST],
)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
self.context["title_placeholders"] = {
@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.13.4"]
"requirements": ["reolink-aio==0.13.5"]
}
+14 -7
View File
@@ -636,14 +636,21 @@ class SamsungTVWSBridge(
)
self._remote = None
except ConnectionFailure as err:
LOGGER.warning(
(
error_details = err.args[0]
if "ms.channel.timeOut" in (error_details := repr(err)):
# The websocket was connected, but the TV is probably asleep
LOGGER.debug(
"Channel timeout occurred trying to get remote for %s: %s",
self.host,
error_details,
)
else:
LOGGER.warning(
"Unexpected ConnectionFailure trying to get remote for %s, "
"please report this issue: %s"
),
self.host,
repr(err),
)
"please report this issue: %s",
self.host,
error_details,
)
self._remote = None
except (WebSocketException, AsyncioTimeoutError, OSError) as err:
LOGGER.debug("Failed to get remote for %s: %s", self.host, repr(err))
@@ -39,7 +39,7 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
)
self.bridge = bridge
self.is_on: bool | None = False
self.is_on: bool | None = None
self.async_extra_update: Callable[[], Coroutine[Any, Any, None]] | None = None
async def _async_update_data(self) -> None:
@@ -52,7 +52,12 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]):
else:
self.is_on = await self.bridge.async_is_on()
if self.is_on != old_state:
LOGGER.debug("TV %s state updated to %s", self.bridge.host, self.is_on)
LOGGER.debug(
"TV %s state updated from %s to %s",
self.bridge.host,
old_state,
self.is_on,
)
if self.async_extra_update:
await self.async_extra_update()
+6 -5
View File
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Final
from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS
from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from homeassistant.components.button import (
@@ -62,7 +62,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
translation_key="self_test",
entity_category=EntityCategory.DIAGNOSTIC,
press_action="trigger_shelly_gas_self_test",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS,
),
ShellyButtonDescription[ShellyBlockCoordinator](
key="mute",
@@ -70,7 +70,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
translation_key="mute",
entity_category=EntityCategory.CONFIG,
press_action="trigger_shelly_gas_mute",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS,
),
ShellyButtonDescription[ShellyBlockCoordinator](
key="unmute",
@@ -78,7 +78,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
translation_key="unmute",
entity_category=EntityCategory.CONFIG,
press_action="trigger_shelly_gas_unmute",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS,
),
]
@@ -89,7 +89,7 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
translation_key="calibrate",
entity_category=EntityCategory.CONFIG,
press_action="trigger_blu_trv_calibration",
supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY,
supported=lambda coordinator: coordinator.model == MODEL_BLU_GATEWAY_G3,
),
]
@@ -160,6 +160,7 @@ async def async_setup_entry(
ShellyBluTrvButton(coordinator, button, id_)
for id_ in blutrv_key_ids
for button in BLU_TRV_BUTTONS
if button.supported(coordinator)
)
async_add_entities(entities)
@@ -218,5 +218,6 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_PASSWORD): cv.string,
}
),
description_placeholders={CONF_HOST: self._data[CONF_HOST]},
errors=errors,
)
+10
View File
@@ -32,6 +32,16 @@
},
"description": "Enter your SMA device information.",
"title": "Set up SMA Solar"
},
"discovery_confirm": {
"title": "[%key:component::sma::config::step::user::title%]",
"description": "Do you want to set up the discovered SMA device ({host})?",
"data": {
"group": "[%key:component::sma::config::step::user::data::group%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl": "[%key:common::config_flow::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
}
}
}
@@ -12,7 +12,7 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.2.5"],
"requirements": ["pysmlight==0.2.6"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
@@ -130,11 +130,11 @@ async def async_generate_speaker_info(
value = getattr(speaker, attrib)
payload[attrib] = get_contents(value)
payload["enabled_entities"] = {
payload["enabled_entities"] = sorted(
entity_id
for entity_id, s in hass.data[DATA_SONOS].entity_id_mappings.items()
if s is speaker
}
)
payload["media"] = await async_generate_media_info(hass, speaker)
payload["activity_stats"] = speaker.activity_stats.report()
payload["event_stats"] = speaker.event_stats.report()
@@ -7,5 +7,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.2.2"]
"requirements": ["PyTurboJPEG==1.8.0", "av==13.1.0", "numpy==2.2.6"]
}
+8 -5
View File
@@ -16,7 +16,10 @@ from homeassistant.helpers.typing import ConfigType
# as we will always load it and we do not want to have
# to wait for the import executor when its busy later
# in the startup process.
from . import sensor as sensor_pre_import # noqa: F401
from . import (
binary_sensor as binary_sensor_pre_import, # noqa: F401
sensor as sensor_pre_import, # noqa: F401
)
from .const import ( # noqa: F401 # noqa: F401
DOMAIN,
STATE_ABOVE_HORIZON,
@@ -24,6 +27,8 @@ from .const import ( # noqa: F401 # noqa: F401
)
from .entity import Sun, SunConfigEntry
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@@ -52,14 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
await component.async_add_entities([sun])
entry.runtime_data = sun
entry.async_on_unload(sun.remove_listeners)
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: SunConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR]
):
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.async_remove()
return unload_ok
@@ -0,0 +1,100 @@
"""Binary Sensor platform for Sun integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SIGNAL_EVENTS_CHANGED
from .entity import Sun, SunConfigEntry
ENTITY_ID_BINARY_SENSOR_FORMAT = BINARY_SENSOR_DOMAIN + ".sun_{}"
@dataclass(kw_only=True, frozen=True)
class SunBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes a Sun binary sensor entity."""
value_fn: Callable[[Sun], bool | None]
signal: str
BINARY_SENSOR_TYPES: tuple[SunBinarySensorEntityDescription, ...] = (
SunBinarySensorEntityDescription(
key="solar_rising",
translation_key="solar_rising",
value_fn=lambda data: data.rising,
entity_registry_enabled_default=False,
signal=SIGNAL_EVENTS_CHANGED,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SunConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sun binary sensor platform."""
sun = entry.runtime_data
async_add_entities(
[
SunBinarySensor(sun, description, entry.entry_id)
for description in BINARY_SENSOR_TYPES
]
)
class SunBinarySensor(BinarySensorEntity):
"""Representation of a Sun binary sensor."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_entity_category = EntityCategory.DIAGNOSTIC
entity_description: SunBinarySensorEntityDescription
def __init__(
self,
sun: Sun,
entity_description: SunBinarySensorEntityDescription,
entry_id: str,
) -> None:
"""Initiate Sun Binary Sensor."""
self.entity_description = entity_description
self.entity_id = ENTITY_ID_BINARY_SENSOR_FORMAT.format(entity_description.key)
self._attr_unique_id = f"{entry_id}-{entity_description.key}"
self.sun = sun
self._attr_device_info = DeviceInfo(
name="Sun",
identifiers={(DOMAIN, entry_id)},
entry_type=DeviceEntryType.SERVICE,
)
@property
def is_on(self) -> bool | None:
"""Return value of binary sensor."""
return self.entity_description.value_fn(self.sun)
async def async_added_to_hass(self) -> None:
"""Register signal listener when added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
self.entity_description.signal,
self.async_write_ha_state,
)
)
+9
View File
@@ -28,6 +28,15 @@
"solar_rising": {
"default": "mdi:sun-clock"
}
},
"binary_sensor": {
"solar_rising": {
"default": "mdi:weather-sunny-off",
"state": {
"on": "mdi:weather-sunset-up",
"off": "mdi:weather-sunset-down"
}
}
}
}
}
@@ -27,6 +27,15 @@
"solar_azimuth": { "name": "Solar azimuth" },
"solar_elevation": { "name": "Solar elevation" },
"solar_rising": { "name": "Solar rising" }
},
"binary_sensor": {
"solar_rising": {
"name": "Solar rising",
"state": {
"on": "Rising",
"off": "Setting"
}
}
}
}
}
@@ -367,7 +367,9 @@ class SwitchbotOptionsFlowHandler(OptionsFlow):
),
): int
}
if self.config_entry.data.get(CONF_SENSOR_TYPE) == SupportedModels.LOCK_PRO:
if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith(
SupportedModels.LOCK
):
options.update(
{
vol.Optional(
@@ -12,7 +12,8 @@ from synology_dsm.exceptions import SynologyDSMNotLoggedInException
from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
@@ -34,10 +35,20 @@ from .coordinator import (
SynologyDSMData,
SynologyDSMSwitchUpdateCoordinator,
)
from .service import async_setup_services
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Synology DSM component."""
await async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool:
"""Set up Synology DSM sensors."""
@@ -89,9 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry)
details = EXCEPTION_UNKNOWN
raise ConfigEntryNotReady(details) from err
# Services
await async_setup_services(hass)
# For SSDP compat
if not entry.data.get(CONF_MAC):
hass.config_entries.async_update_entry(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
"""Telegram bot classes and utilities."""
from abc import abstractmethod
import asyncio
import io
import logging
from types import MappingProxyType
from typing import Any
import httpx
from telegram import (
Bot,
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
Update,
User,
)
from telegram.constants import ParseMode
from telegram.error import TelegramError
from telegram.ext import CallbackContext, filters
from telegram.request import HTTPXRequest
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_COMMAND,
CONF_API_KEY,
HTTP_BEARER_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.util.ssl import get_default_context, get_default_no_verify_context
from .const import (
ATTR_ARGS,
ATTR_AUTHENTICATION,
ATTR_CAPTION,
ATTR_CHAT_ID,
ATTR_CHAT_INSTANCE,
ATTR_DATA,
ATTR_DATE,
ATTR_DISABLE_NOTIF,
ATTR_DISABLE_WEB_PREV,
ATTR_FILE,
ATTR_FROM_FIRST,
ATTR_FROM_LAST,
ATTR_KEYBOARD,
ATTR_KEYBOARD_INLINE,
ATTR_MESSAGE,
ATTR_MESSAGE_TAG,
ATTR_MESSAGE_THREAD_ID,
ATTR_MESSAGEID,
ATTR_MSG,
ATTR_MSGID,
ATTR_ONE_TIME_KEYBOARD,
ATTR_OPEN_PERIOD,
ATTR_PARSER,
ATTR_PASSWORD,
ATTR_REPLY_TO_MSGID,
ATTR_REPLYMARKUP,
ATTR_RESIZE_KEYBOARD,
ATTR_STICKER_ID,
ATTR_TEXT,
ATTR_TIMEOUT,
ATTR_TITLE,
ATTR_URL,
ATTR_USER_ID,
ATTR_USERNAME,
ATTR_VERIFY_SSL,
CONF_CHAT_ID,
CONF_PROXY_PARAMS,
CONF_PROXY_URL,
DOMAIN,
EVENT_TELEGRAM_CALLBACK,
EVENT_TELEGRAM_COMMAND,
EVENT_TELEGRAM_SENT,
EVENT_TELEGRAM_TEXT,
PARSER_HTML,
PARSER_MD,
PARSER_MD2,
PARSER_PLAIN_TEXT,
SERVICE_EDIT_CAPTION,
SERVICE_EDIT_MESSAGE,
SERVICE_SEND_ANIMATION,
SERVICE_SEND_DOCUMENT,
SERVICE_SEND_PHOTO,
SERVICE_SEND_STICKER,
SERVICE_SEND_VIDEO,
SERVICE_SEND_VOICE,
)
_LOGGER = logging.getLogger(__name__)
type TelegramBotConfigEntry = ConfigEntry[TelegramNotificationService]
class BaseTelegramBot:
"""The base class for the telegram bot."""
def __init__(self, hass: HomeAssistant, config: TelegramBotConfigEntry) -> None:
"""Initialize the bot base class."""
self.hass = hass
self.config = config
@abstractmethod
async def shutdown(self) -> None:
"""Shutdown the bot application."""
async def handle_update(self, update: Update, context: CallbackContext) -> bool:
"""Handle updates from bot application set up by the respective platform."""
_LOGGER.debug("Handling update %s", update)
if not self.authorize_update(update):
return False
# establish event type: text, command or callback_query
if update.callback_query:
# NOTE: Check for callback query first since effective message will be populated with the message
# in .callback_query (python-telegram-bot docs are wrong)
event_type, event_data = self._get_callback_query_event_data(
update.callback_query
)
elif update.effective_message:
event_type, event_data = self._get_message_event_data(
update.effective_message
)
else:
_LOGGER.warning("Unhandled update: %s", update)
return True
event_context = Context()
_LOGGER.debug("Firing event %s: %s", event_type, event_data)
self.hass.bus.async_fire(event_type, event_data, context=event_context)
return True
@staticmethod
def _get_command_event_data(command_text: str | None) -> dict[str, str | list]:
if not command_text or not command_text.startswith("/"):
return {}
command_parts = command_text.split()
command = command_parts[0]
args = command_parts[1:]
return {ATTR_COMMAND: command, ATTR_ARGS: args}
def _get_message_event_data(self, message: Message) -> tuple[str, dict[str, Any]]:
event_data: dict[str, Any] = {
ATTR_MSGID: message.message_id,
ATTR_CHAT_ID: message.chat.id,
ATTR_DATE: message.date,
ATTR_MESSAGE_THREAD_ID: message.message_thread_id,
}
if filters.COMMAND.filter(message):
# This is a command message - set event type to command and split data into command and args
event_type = EVENT_TELEGRAM_COMMAND
event_data.update(self._get_command_event_data(message.text))
else:
event_type = EVENT_TELEGRAM_TEXT
event_data[ATTR_TEXT] = message.text
if message.from_user:
event_data.update(self._get_user_event_data(message.from_user))
return event_type, event_data
def _get_user_event_data(self, user: User) -> dict[str, Any]:
return {
ATTR_USER_ID: user.id,
ATTR_FROM_FIRST: user.first_name,
ATTR_FROM_LAST: user.last_name,
}
def _get_callback_query_event_data(
self, callback_query: CallbackQuery
) -> tuple[str, dict[str, Any]]:
event_type = EVENT_TELEGRAM_CALLBACK
event_data: dict[str, Any] = {
ATTR_MSGID: callback_query.id,
ATTR_CHAT_INSTANCE: callback_query.chat_instance,
ATTR_DATA: callback_query.data,
ATTR_MSG: None,
ATTR_CHAT_ID: None,
}
if callback_query.message:
event_data[ATTR_MSG] = callback_query.message.to_dict()
event_data[ATTR_CHAT_ID] = callback_query.message.chat.id
if callback_query.from_user:
event_data.update(self._get_user_event_data(callback_query.from_user))
# Split data into command and args if possible
event_data.update(self._get_command_event_data(callback_query.data))
return event_type, event_data
def authorize_update(self, update: Update) -> bool:
"""Make sure either user or chat is in allowed_chat_ids."""
from_user = update.effective_user.id if update.effective_user else None
from_chat = update.effective_chat.id if update.effective_chat else None
allowed_chat_ids: list[int] = [
subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values()
]
if from_user in allowed_chat_ids or from_chat in allowed_chat_ids:
return True
_LOGGER.error(
(
"Unauthorized update - neither user id %s nor chat id %s is in allowed"
" chats: %s"
),
from_user,
from_chat,
allowed_chat_ids,
)
return False
class TelegramNotificationService:
"""Implement the notification services for the Telegram Bot domain."""
def __init__(
self,
hass: HomeAssistant,
app: BaseTelegramBot,
bot: Bot,
config: TelegramBotConfigEntry,
parser: str,
) -> None:
"""Initialize the service."""
self.app = app
self.config = config
self._parsers = {
PARSER_HTML: ParseMode.HTML,
PARSER_MD: ParseMode.MARKDOWN,
PARSER_MD2: ParseMode.MARKDOWN_V2,
PARSER_PLAIN_TEXT: None,
}
self._parse_mode = self._parsers.get(parser)
self.bot = bot
self.hass = hass
def _get_allowed_chat_ids(self) -> list[int]:
allowed_chat_ids: list[int] = [
subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values()
]
if not allowed_chat_ids:
bot_name: str = self.config.title
raise ServiceValidationError(
"No allowed chat IDs found for bot",
translation_domain=DOMAIN,
translation_key="missing_allowed_chat_ids",
translation_placeholders={
"bot_name": bot_name,
},
)
return allowed_chat_ids
def _get_last_message_id(self):
return dict.fromkeys(self._get_allowed_chat_ids())
def _get_msg_ids(self, msg_data, chat_id):
"""Get the message id to edit.
This can be one of (message_id, inline_message_id) from a msg dict,
returning a tuple.
**You can use 'last' as message_id** to edit
the message last sent in the chat_id.
"""
message_id = inline_message_id = None
if ATTR_MESSAGEID in msg_data:
message_id = msg_data[ATTR_MESSAGEID]
if (
isinstance(message_id, str)
and (message_id == "last")
and (self._get_last_message_id()[chat_id] is not None)
):
message_id = self._get_last_message_id()[chat_id]
else:
inline_message_id = msg_data["inline_message_id"]
return message_id, inline_message_id
def _get_target_chat_ids(self, target):
"""Validate chat_id targets or return default target (first).
:param target: optional list of integers ([12234, -12345])
:return list of chat_id targets (integers)
"""
allowed_chat_ids: list[int] = self._get_allowed_chat_ids()
default_user: int = allowed_chat_ids[0]
if target is not None:
if isinstance(target, int):
target = [target]
chat_ids = [t for t in target if t in allowed_chat_ids]
if chat_ids:
return chat_ids
_LOGGER.warning(
"Disallowed targets: %s, using default: %s", target, default_user
)
return [default_user]
def _get_msg_kwargs(self, data):
"""Get parameters in message data kwargs."""
def _make_row_inline_keyboard(row_keyboard):
"""Make a list of InlineKeyboardButtons.
It can accept:
- a list of tuples like:
`[(text_b1, data_callback_b1),
(text_b2, data_callback_b2), ...]
- a string like: `/cmd1, /cmd2, /cmd3`
- or a string like: `text_b1:/cmd1, text_b2:/cmd2`
- also supports urls instead of callback commands
"""
buttons = []
if isinstance(row_keyboard, str):
for key in row_keyboard.split(","):
if ":/" in key:
# check if command or URL
if key.startswith("https://"):
label = key.split(",")[0]
url = key[len(label) + 1 :]
buttons.append(InlineKeyboardButton(label, url=url))
else:
# commands like: 'Label:/cmd' become ('Label', '/cmd')
label = key.split(":/")[0]
command = key[len(label) + 1 :]
buttons.append(
InlineKeyboardButton(label, callback_data=command)
)
else:
# commands like: '/cmd' become ('CMD', '/cmd')
label = key.strip()[1:].upper()
buttons.append(InlineKeyboardButton(label, callback_data=key))
elif isinstance(row_keyboard, list):
for entry in row_keyboard:
text_btn, data_btn = entry
if data_btn.startswith("https://"):
buttons.append(InlineKeyboardButton(text_btn, url=data_btn))
else:
buttons.append(
InlineKeyboardButton(text_btn, callback_data=data_btn)
)
else:
raise TypeError(str(row_keyboard))
return buttons
# Defaults
params = {
ATTR_PARSER: self._parse_mode,
ATTR_DISABLE_NOTIF: False,
ATTR_DISABLE_WEB_PREV: None,
ATTR_REPLY_TO_MSGID: None,
ATTR_REPLYMARKUP: None,
ATTR_TIMEOUT: None,
ATTR_MESSAGE_TAG: None,
ATTR_MESSAGE_THREAD_ID: None,
}
if data is not None:
if ATTR_PARSER in data:
params[ATTR_PARSER] = self._parsers.get(
data[ATTR_PARSER], self._parse_mode
)
if ATTR_TIMEOUT in data:
params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT]
if ATTR_DISABLE_NOTIF in data:
params[ATTR_DISABLE_NOTIF] = data[ATTR_DISABLE_NOTIF]
if ATTR_DISABLE_WEB_PREV in data:
params[ATTR_DISABLE_WEB_PREV] = data[ATTR_DISABLE_WEB_PREV]
if ATTR_REPLY_TO_MSGID in data:
params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID]
if ATTR_MESSAGE_TAG in data:
params[ATTR_MESSAGE_TAG] = data[ATTR_MESSAGE_TAG]
if ATTR_MESSAGE_THREAD_ID in data:
params[ATTR_MESSAGE_THREAD_ID] = data[ATTR_MESSAGE_THREAD_ID]
# Keyboards:
if ATTR_KEYBOARD in data:
keys = data.get(ATTR_KEYBOARD)
keys = keys if isinstance(keys, list) else [keys]
if keys:
params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup(
[[key.strip() for key in row.split(",")] for row in keys],
resize_keyboard=data.get(ATTR_RESIZE_KEYBOARD, False),
one_time_keyboard=data.get(ATTR_ONE_TIME_KEYBOARD, False),
)
else:
params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True)
elif ATTR_KEYBOARD_INLINE in data:
keys = data.get(ATTR_KEYBOARD_INLINE)
keys = keys if isinstance(keys, list) else [keys]
params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup(
[_make_row_inline_keyboard(row) for row in keys]
)
return params
async def _send_msg(
self, func_send, msg_error, message_tag, *args_msg, context=None, **kwargs_msg
):
"""Send one message."""
try:
out = await func_send(*args_msg, **kwargs_msg)
if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID):
chat_id = out.chat_id
message_id = out[ATTR_MESSAGEID]
self._get_last_message_id()[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
self._get_last_message_id(),
chat_id,
)
event_data = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGEID: message_id,
}
if message_tag is not None:
event_data[ATTR_MESSAGE_TAG] = message_tag
if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None:
event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[
ATTR_MESSAGE_THREAD_ID
]
self.hass.bus.async_fire(
EVENT_TELEGRAM_SENT, event_data, context=context
)
elif not isinstance(out, bool):
_LOGGER.warning(
"Update last message: out_type:%s, out=%s", type(out), out
)
except TelegramError as exc:
_LOGGER.error(
"%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg
)
return None
return out
async def send_message(self, message="", target=None, context=None, **kwargs):
"""Send a message to one or multiple pre-allowed chat IDs."""
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params)
msg = await self._send_msg(
self.bot.send_message,
"Error sending message",
params[ATTR_MESSAGE_TAG],
chat_id,
text,
parse_mode=params[ATTR_PARSER],
disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
if msg is not None:
msg_ids[chat_id] = msg.id
return msg_ids
async def delete_message(self, chat_id=None, context=None, **kwargs):
"""Delete a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted = await self._send_msg(
self.bot.delete_message,
"Error deleting message",
None,
chat_id,
message_id,
context=context,
)
# reduce message_id anyway:
if self._get_last_message_id()[chat_id] is not None:
# change last msg_id for deque(n_msgs)?
self._get_last_message_id()[chat_id] -= 1
return deleted
async def edit_message(self, type_edit, chat_id=None, context=None, **kwargs):
"""Edit a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
"Edit message %s in chat ID %s with params: %s",
message_id or inline_message_id,
chat_id,
params,
)
if type_edit == SERVICE_EDIT_MESSAGE:
message = kwargs.get(ATTR_MESSAGE)
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
_LOGGER.debug("Editing message with ID %s", message_id or inline_message_id)
return await self._send_msg(
self.bot.edit_message_text,
"Error editing text message",
params[ATTR_MESSAGE_TAG],
text,
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
parse_mode=params[ATTR_PARSER],
disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
if type_edit == SERVICE_EDIT_CAPTION:
return await self._send_msg(
self.bot.edit_message_caption,
"Error editing message attributes",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
caption=kwargs.get(ATTR_CAPTION),
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
context=context,
)
return await self._send_msg(
self.bot.edit_message_reply_markup,
"Error editing message attributes",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def answer_callback_query(
self, message, callback_query_id, show_alert=False, context=None, **kwargs
):
"""Answer a callback originated with a press in an inline keyboard."""
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
"Answer callback query with callback ID %s: %s, alert: %s",
callback_query_id,
message,
show_alert,
)
await self._send_msg(
self.bot.answer_callback_query,
"Error sending answer callback query",
params[ATTR_MESSAGE_TAG],
callback_query_id,
text=message,
show_alert=show_alert,
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def send_file(
self, file_type=SERVICE_SEND_PHOTO, target=None, context=None, **kwargs
):
"""Send a photo, sticker, video, or document."""
params = self._get_msg_kwargs(kwargs)
file_content = await load_data(
self.hass,
url=kwargs.get(ATTR_URL),
filepath=kwargs.get(ATTR_FILE),
username=kwargs.get(ATTR_USERNAME),
password=kwargs.get(ATTR_PASSWORD),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=(
get_default_context()
if kwargs.get(ATTR_VERIFY_SSL, False)
else get_default_no_verify_context()
),
)
msg_ids = {}
if file_content:
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Sending file to chat ID %s", chat_id)
if file_type == SERVICE_SEND_PHOTO:
msg = await self._send_msg(
self.bot.send_photo,
"Error sending photo",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
photo=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
elif file_type == SERVICE_SEND_STICKER:
msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
sticker=file_content,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
elif file_type == SERVICE_SEND_VIDEO:
msg = await self._send_msg(
self.bot.send_video,
"Error sending video",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
video=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
elif file_type == SERVICE_SEND_DOCUMENT:
msg = await self._send_msg(
self.bot.send_document,
"Error sending document",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
document=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
elif file_type == SERVICE_SEND_VOICE:
msg = await self._send_msg(
self.bot.send_voice,
"Error sending voice",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
voice=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
elif file_type == SERVICE_SEND_ANIMATION:
msg = await self._send_msg(
self.bot.send_animation,
"Error sending animation",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
animation=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
msg_ids[chat_id] = msg.id
file_content.seek(0)
else:
_LOGGER.error("Can't send file with kwargs: %s", kwargs)
return msg_ids
async def send_sticker(self, target=None, context=None, **kwargs) -> dict:
"""Send a sticker from a telegram sticker pack."""
params = self._get_msg_kwargs(kwargs)
stickerid = kwargs.get(ATTR_STICKER_ID)
msg_ids = {}
if stickerid:
for chat_id in self._get_target_chat_ids(target):
msg = await self._send_msg(
self.bot.send_sticker,
"Error sending sticker",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
sticker=stickerid,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
msg_ids[chat_id] = msg.id
return msg_ids
return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs)
async def send_location(
self, latitude, longitude, target=None, context=None, **kwargs
):
"""Send a location."""
latitude = float(latitude)
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug(
"Send location %s/%s to chat ID %s", latitude, longitude, chat_id
)
msg = await self._send_msg(
self.bot.send_location,
"Error sending location",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
latitude=latitude,
longitude=longitude,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
msg_ids[chat_id] = msg.id
return msg_ids
async def send_poll(
self,
question,
options,
is_anonymous,
allows_multiple_answers,
target=None,
context=None,
**kwargs,
):
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
msg_ids = {}
for chat_id in self._get_target_chat_ids(target):
_LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id)
msg = await self._send_msg(
self.bot.send_poll,
"Error sending poll",
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
question=question,
options=options,
is_anonymous=is_anonymous,
allows_multiple_answers=allows_multiple_answers,
open_period=openperiod,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
msg_ids[chat_id] = msg.id
return msg_ids
async def leave_chat(self, chat_id=None, context=None, **kwargs):
"""Remove bot from chat."""
chat_id = self._get_target_chat_ids(chat_id)[0]
_LOGGER.debug("Leave from chat ID %s", chat_id)
return await self._send_msg(
self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context
)
def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> Bot:
"""Initialize telegram bot with proxy support."""
api_key: str = p_config[CONF_API_KEY]
proxy_url: str | None = p_config.get(CONF_PROXY_URL)
proxy_params: dict | None = p_config.get(CONF_PROXY_PARAMS)
if proxy_url is not None:
auth = None
if proxy_params is None:
# CONF_PROXY_PARAMS has been kept for backwards compatibility.
proxy_params = {}
elif "username" in proxy_params and "password" in proxy_params:
# Auth can actually be stuffed into the URL, but the docs have previously
# indicated to put them here.
auth = proxy_params.pop("username"), proxy_params.pop("password")
ir.create_issue(
hass,
DOMAIN,
"proxy_params_auth_deprecation",
breaks_in_ha_version="2024.10.0",
is_persistent=False,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_placeholders={
"proxy_params": CONF_PROXY_PARAMS,
"proxy_url": CONF_PROXY_URL,
"telegram_bot": "Telegram bot",
},
translation_key="proxy_params_auth_deprecation",
learn_more_url="https://github.com/home-assistant/core/pull/112778",
)
else:
ir.create_issue(
hass,
DOMAIN,
"proxy_params_deprecation",
breaks_in_ha_version="2024.10.0",
is_persistent=False,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_placeholders={
"proxy_params": CONF_PROXY_PARAMS,
"proxy_url": CONF_PROXY_URL,
"httpx": "httpx",
"telegram_bot": "Telegram bot",
},
translation_key="proxy_params_deprecation",
learn_more_url="https://github.com/home-assistant/core/pull/112778",
)
proxy = httpx.Proxy(proxy_url, auth=auth, **proxy_params)
request = HTTPXRequest(connection_pool_size=8, proxy=proxy)
else:
request = HTTPXRequest(connection_pool_size=8)
return Bot(token=api_key, request=request)
async def load_data(
hass: HomeAssistant,
url=None,
filepath=None,
username=None,
password=None,
authentication=None,
num_retries=5,
verify_ssl=None,
):
"""Load data into ByteIO/File container from a source."""
try:
if url is not None:
# Load data from URL
params: dict[str, Any] = {}
headers = {}
if authentication == HTTP_BEARER_AUTHENTICATION and password is not None:
headers = {"Authorization": f"Bearer {password}"}
elif username is not None and password is not None:
if authentication == HTTP_DIGEST_AUTHENTICATION:
params["auth"] = httpx.DigestAuth(username, password)
else:
params["auth"] = httpx.BasicAuth(username, password)
if verify_ssl is not None:
params["verify"] = verify_ssl
retry_num = 0
async with httpx.AsyncClient(
timeout=15, headers=headers, **params
) as client:
while retry_num < num_retries:
req = await client.get(url)
if req.status_code != 200:
_LOGGER.warning(
"Status code %s (retry #%s) loading %s",
req.status_code,
retry_num + 1,
url,
)
else:
data = io.BytesIO(req.content)
if data.read():
data.seek(0)
data.name = url
return data
_LOGGER.warning(
"Empty data (retry #%s) in %s)", retry_num + 1, url
)
retry_num += 1
if retry_num < num_retries:
await asyncio.sleep(
1
) # Add a sleep to allow other async operations to proceed
_LOGGER.warning(
"Can't load data in %s after %s retries", url, retry_num
)
elif filepath is not None:
if hass.config.is_allowed_path(filepath):
return await hass.async_add_executor_job(
_read_file_as_bytesio, filepath
)
_LOGGER.warning("'%s' are not secure to load data from!", filepath)
else:
_LOGGER.warning("Can't load data. No data found in params!")
except (OSError, TypeError) as error:
_LOGGER.error("Can't load data into ByteIO: %s", error)
return None
def _read_file_as_bytesio(file_path: str) -> io.BytesIO:
"""Read a file and return it as a BytesIO object."""
with open(file_path, "rb") as file:
data = io.BytesIO(file.read())
data.name = file_path
return data

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