mirror of
https://github.com/home-assistant/core.git
synced 2026-05-20 15:55:17 +02:00
Compare commits
282 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ec5d1c0b7 | |||
| 0eecb03b84 | |||
| 0c22c13b1f | |||
| bf56fad3f9 | |||
| 078d40ac54 | |||
| 1b7bda06d3 | |||
| 828dde26e5 | |||
| e8d21e57b3 | |||
| 481965eb0d | |||
| 2fcfa8320f | |||
| 95c68da115 | |||
| d547076033 | |||
| db0006c100 | |||
| f8d4826bf3 | |||
| 88f6b7159a | |||
| f7faed7330 | |||
| 302148b078 | |||
| 5b2816e56c | |||
| f7cf279648 | |||
| ee83a14391 | |||
| 833ff982d0 | |||
| d8cb3ab4b8 | |||
| 23b0f550b1 | |||
| c66eeed8f8 | |||
| bdc9d881ea | |||
| 95e2f5e219 | |||
| 68fc5c0e87 | |||
| 67c1930c6f | |||
| c90017d207 | |||
| 9dce6943de | |||
| 6a5faf2ec7 | |||
| d0711624c0 | |||
| 03ea95dfd4 | |||
| 721c736c03 | |||
| 1c105a5766 | |||
| ad0324631b | |||
| eaa980f466 | |||
| 83fbea2158 | |||
| 74c918b6b6 | |||
| ff7964bcfc | |||
| 9a1fd913bf | |||
| f0396aca8a | |||
| 018e3a4765 | |||
| 2af7f43ed7 | |||
| 95878222fd | |||
| 95f3bd7c09 | |||
| c366beab2e | |||
| 88277d5920 | |||
| 5e0aefd539 | |||
| ff313f1e7f | |||
| 70f9395d02 | |||
| b96f904d15 | |||
| 0d16fa1e65 | |||
| 27816fcb0c | |||
| 4f0faf43c6 | |||
| c28f5d3eed | |||
| 7b589d6ce8 | |||
| b5556e17b2 | |||
| 407d29396a | |||
| 7eaa132189 | |||
| 87b151a436 | |||
| 19cbb3e5c9 | |||
| 675bbd704c | |||
| 30a51e643f | |||
| 6ae50fffe1 | |||
| e60704ccec | |||
| ad9a7c08ab | |||
| 198cb331ed | |||
| fc8949d4a2 | |||
| ed74360485 | |||
| 9109cb5bfb | |||
| 5c29580969 | |||
| 3813843c8c | |||
| 5ac7f898dd | |||
| 85e7c12535 | |||
| 5472a537ed | |||
| a1e22c22d3 | |||
| a69e0ca7c5 | |||
| fb129255b5 | |||
| f521e35e68 | |||
| e7eb508277 | |||
| 816544b5e9 | |||
| e46ad54f51 | |||
| 2aa95b1280 | |||
| 542afb5081 | |||
| 32dde39a77 | |||
| 81280f9cca | |||
| bbd6aa83b1 | |||
| cbcf3f3eb4 | |||
| 362bcf722c | |||
| 54da9649dc | |||
| 31f98c12af | |||
| 5f811e856f | |||
| 4e7bfe2e48 | |||
| 5e383eeb2e | |||
| 84019955ce | |||
| 7da2014ff2 | |||
| df541bd30e | |||
| 905fd28112 | |||
| eee9449e6b | |||
| bdd6b34055 | |||
| 17e206d749 | |||
| 9a49696fca | |||
| 77fed7ae7b | |||
| 9feb450de0 | |||
| a9207988f5 | |||
| 63b199c865 | |||
| 92285c37a2 | |||
| 73e0624964 | |||
| 057db984ed | |||
| 0fa1561d7a | |||
| cffcaa9e78 | |||
| cb9fd1abc0 | |||
| bff3f1c062 | |||
| 6068e78216 | |||
| c80c48ab0a | |||
| be52471cbb | |||
| fc7a6b48e2 | |||
| 75a5fcbee7 | |||
| e61835df17 | |||
| 6062d12f34 | |||
| 3425bd22ba | |||
| 6d2cc5d6cf | |||
| d61dc0b67f | |||
| 20d7aa5523 | |||
| 6d10249a93 | |||
| d10dc679e7 | |||
| 2753309946 | |||
| dc3e3efc66 | |||
| e3eb165f9b | |||
| dec693ca14 | |||
| 3d42a2508c | |||
| 0d93b9b9f4 | |||
| c89612eda5 | |||
| b1e0ce48ae | |||
| 7f04d47ff3 | |||
| b9575ee881 | |||
| dac2c91ae5 | |||
| ef29183160 | |||
| 4ba0dc5cc2 | |||
| daffc8c2ce | |||
| 0ed8d24b54 | |||
| cd7a8c68f9 | |||
| cddad5b790 | |||
| eae809abd1 | |||
| bb964ccd95 | |||
| 2ec51ef113 | |||
| e5682d97a6 | |||
| 5d8087441c | |||
| 211cccfab8 | |||
| e0ac404f96 | |||
| 7cdb6d27e7 | |||
| 60a2e811c0 | |||
| bc0899ba10 | |||
| 8304f35734 | |||
| 2eb0701792 | |||
| 972240f994 | |||
| 017f85243a | |||
| 02666f8762 | |||
| e8ea01980e | |||
| 62a79389ed | |||
| 0614fde503 | |||
| 90d9314c36 | |||
| ed250370ab | |||
| b5580db910 | |||
| a1b138acd2 | |||
| 7b4d2bdd6d | |||
| 7f08212efd | |||
| e66b24b0fc | |||
| febfd409d3 | |||
| 5f3a549851 | |||
| da1246c3cf | |||
| 5b082b5377 | |||
| a1de03d63e | |||
| 4696e428cf | |||
| 0cca80e57d | |||
| eb45c35952 | |||
| b62b8a0f3f | |||
| 4d7737f4d3 | |||
| 500f4ea796 | |||
| 846300df28 | |||
| ec918e0460 | |||
| 885bb41416 | |||
| 3747fa3b8b | |||
| 98f5c818e1 | |||
| 3514d9d3ca | |||
| 8ba113769d | |||
| eef03f5def | |||
| c924bba41e | |||
| 1369fe2451 | |||
| fec97f383d | |||
| ec3c61a877 | |||
| 993be3c0c8 | |||
| 1e5f618ff9 | |||
| efaf22270d | |||
| f9c6da812d | |||
| 286e5f246f | |||
| ef33cd58fd | |||
| e4c44f873a | |||
| a1d8cf5cdf | |||
| 53bb8c7012 | |||
| 6cb1e05784 | |||
| b3eb97adad | |||
| 0b0618e5c8 | |||
| 5c588dd5d3 | |||
| 555d838290 | |||
| d7568a8f16 | |||
| 1bf3bfc082 | |||
| 1e8a9ded70 | |||
| eaf73d6526 | |||
| 150e241601 | |||
| 02928ccadd | |||
| 57615b8c66 | |||
| 66bac035c3 | |||
| b2ed78b317 | |||
| 3d65d42d47 | |||
| 74af1fd5d4 | |||
| 793018e0d6 | |||
| a4865a69b9 | |||
| a07034c13b | |||
| ccc454f8f9 | |||
| 31b348f3cd | |||
| c5e14bd1a4 | |||
| e8a36f7128 | |||
| 4e01805270 | |||
| f0eb151cdd | |||
| 3527556e58 | |||
| a4bfbd3dde | |||
| ff971ce20b | |||
| f82bc56dfa | |||
| 0d0a6d4c91 | |||
| b9105db16c | |||
| ab0f791851 | |||
| 3ddd37242c | |||
| 8a5cb61613 | |||
| 8a77661c14 | |||
| e021119fba | |||
| 237fef8220 | |||
| ee674cfa3f | |||
| 7fc4a5cf66 | |||
| 5f84cdf4b0 | |||
| da9deb1c75 | |||
| 100d729ab6 | |||
| d16da986d8 | |||
| 868769efff | |||
| 97a7a5623b | |||
| 0c44e77ae4 | |||
| 24dadf3668 | |||
| a19c2ee9ef | |||
| 6f7b571dae | |||
| 5753da7dff | |||
| 97f3b48bf8 | |||
| f2e6cd297d | |||
| 011ebec001 | |||
| 2e738e22d2 | |||
| b843047d9a | |||
| ebf1195dc6 | |||
| ab1f92309f | |||
| d3b2be7e86 | |||
| a2131c0d45 | |||
| b179d71658 | |||
| 070ef8f0b0 | |||
| aaeb55b132 | |||
| 1f5cb05f50 | |||
| cee87ed1f5 | |||
| e2ae9c1b95 | |||
| 8b257cdd6c | |||
| f756392b6a | |||
| 894ee88033 | |||
| d5d56e6e23 | |||
| a19a1ec6e8 | |||
| b98015dc76 | |||
| 4112b2af07 | |||
| 944c0d7ed2 | |||
| a471f7059f | |||
| cd1d4244ae | |||
| 573409dcbf | |||
| 6ec70734c1 | |||
| adf6213c9f | |||
| e925672bb6 | |||
| 15c5e257f5 | |||
| 8396964023 |
@@ -16,9 +16,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
|
||||
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
|
||||
|
||||
## Development Commands
|
||||
|
||||
.vscode/tasks.json contains useful commands used for development.
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -28,10 +34,14 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `uv run pytest` to run tests
|
||||
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -128,7 +128,8 @@
|
||||
"home-assistant-bluetooth",
|
||||
"home-assistant-frontend",
|
||||
"home-assistant-intents",
|
||||
"infrared-protocols"
|
||||
"infrared-protocols",
|
||||
"rf-protocols"
|
||||
],
|
||||
"enabled": true,
|
||||
"minimumReleaseAge": null,
|
||||
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
|
||||
@@ -632,7 +632,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
@@ -1088,6 +1088,7 @@ jobs:
|
||||
options: >-
|
||||
--health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/mysql:size=2g,mode=0750
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
@@ -1245,7 +1246,10 @@ jobs:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: password
|
||||
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
options: >-
|
||||
--health-cmd="pg_isready -hlocalhost -Upostgres"
|
||||
--health-interval=5s --health-timeout=2s --health-retries=3
|
||||
--tmpfs /var/lib/postgresql/data:size=2g,mode=0700
|
||||
needs:
|
||||
- info
|
||||
- base
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -250,6 +250,7 @@ homeassistant.components.gpsd.*
|
||||
homeassistant.components.greeneye_monitor.*
|
||||
homeassistant.components.group.*
|
||||
homeassistant.components.guardian.*
|
||||
homeassistant.components.guntamatic.*
|
||||
homeassistant.components.habitica.*
|
||||
homeassistant.components.hardkernel.*
|
||||
homeassistant.components.hardware.*
|
||||
@@ -357,6 +358,7 @@ homeassistant.components.lunatone.*
|
||||
homeassistant.components.lutron.*
|
||||
homeassistant.components.madvr.*
|
||||
homeassistant.components.manual.*
|
||||
homeassistant.components.marantz_infrared.*
|
||||
homeassistant.components.mastodon.*
|
||||
homeassistant.components.matrix.*
|
||||
homeassistant.components.matter.*
|
||||
@@ -423,6 +425,7 @@ homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
@@ -489,6 +492,7 @@ homeassistant.components.rss_feed_template.*
|
||||
homeassistant.components.russound_rio.*
|
||||
homeassistant.components.ruuvi_gateway.*
|
||||
homeassistant.components.ruuvitag_ble.*
|
||||
homeassistant.components.samsung_infrared.*
|
||||
homeassistant.components.samsungtv.*
|
||||
homeassistant.components.saunum.*
|
||||
homeassistant.components.scene.*
|
||||
|
||||
@@ -6,9 +6,15 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- When opening a pull request, use the repository's PR template (`.github/PULL_REQUEST_TEMPLATE.md`). NEVER REMOVE ANYTHING from the template.
|
||||
- Do not remove checkboxes that are not checked — leave all unchecked checkboxes in place so reviewers can see which options were not selected.
|
||||
|
||||
## Development Commands
|
||||
|
||||
.vscode/tasks.json contains useful commands used for development.
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
@@ -18,10 +24,14 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `uv run pytest` to run tests
|
||||
- After modifying `strings.json` for an integration, regenerate the English translation file before running tests: `.venv/bin/python3 -m script.translations develop --integration <integration_name>`. Tests load translations from the generated `translations/en.json`, not directly from `strings.json`.
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+14
-6
@@ -464,8 +464,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/electrasmart/ @jafar-atili
|
||||
/homeassistant/components/electric_kiwi/ @mikey0000
|
||||
/tests/components/electric_kiwi/ @mikey0000
|
||||
/homeassistant/components/electrolux/ @electrolux-oss
|
||||
/tests/components/electrolux/ @electrolux-oss
|
||||
/homeassistant/components/elevenlabs/ @sorgfresser
|
||||
/tests/components/elevenlabs/ @sorgfresser
|
||||
/homeassistant/components/elgato/ @frenck
|
||||
@@ -697,6 +695,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/growatt_server/ @johanzander
|
||||
/homeassistant/components/guardian/ @bachya
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/guntamatic/ @JensTimmerman
|
||||
/tests/components/guntamatic/ @JensTimmerman
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
/tests/components/habitica/ @tr4nt0r
|
||||
/homeassistant/components/hanna/ @bestycame
|
||||
@@ -981,8 +981,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lektrico/ @lektrico
|
||||
/homeassistant/components/letpot/ @jpelgrom
|
||||
/tests/components/letpot/ @jpelgrom
|
||||
/homeassistant/components/lg_infrared/ @home-assistant/core
|
||||
/tests/components/lg_infrared/ @home-assistant/core
|
||||
/homeassistant/components/lg_infrared/ @abmantis
|
||||
/tests/components/lg_infrared/ @abmantis
|
||||
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
|
||||
/tests/components/lg_netcast/ @Drafteed @splinter98
|
||||
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
|
||||
@@ -1045,6 +1045,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/lyric/ @timmo001
|
||||
/homeassistant/components/madvr/ @iloveicedgreentea
|
||||
/tests/components/madvr/ @iloveicedgreentea
|
||||
/homeassistant/components/marantz_infrared/ @balloob
|
||||
/tests/components/marantz_infrared/ @balloob
|
||||
/homeassistant/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/tests/components/mastodon/ @fabaff @andrew-codechimp
|
||||
/homeassistant/components/matrix/ @PaarthShah
|
||||
@@ -1307,6 +1309,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/osoenergy/ @osohotwateriot
|
||||
/homeassistant/components/otbr/ @home-assistant/core
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ouman_eh_800/ @Markus98
|
||||
/tests/components/ouman_eh_800/ @Markus98
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
@@ -1530,6 +1534,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/sabnzbd/ @shaiu @jpbede
|
||||
/tests/components/sabnzbd/ @shaiu @jpbede
|
||||
/homeassistant/components/saj/ @fredericvl
|
||||
/homeassistant/components/samsung_infrared/ @lmaertin
|
||||
/tests/components/samsung_infrared/ @lmaertin
|
||||
/homeassistant/components/samsungtv/ @chemelli74 @epenet
|
||||
/tests/components/samsungtv/ @chemelli74 @epenet
|
||||
/homeassistant/components/sanix/ @tomaszsluszniak
|
||||
@@ -2028,6 +2034,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
|
||||
/homeassistant/components/xiaomi_tv/ @simse
|
||||
/homeassistant/components/xmpp/ @fabaff @flowolf
|
||||
/homeassistant/components/xthings_cloud/ @XthingsJacobs
|
||||
/tests/components/xthings_cloud/ @XthingsJacobs
|
||||
/homeassistant/components/yale/ @bdraco
|
||||
/tests/components/yale/ @bdraco
|
||||
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
|
||||
@@ -2058,8 +2066,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/zeroconf/ @bdraco
|
||||
/homeassistant/components/zerproc/ @emlove
|
||||
/tests/components/zerproc/ @emlove
|
||||
/homeassistant/components/zeversolar/ @kvanzuijlen
|
||||
/tests/components/zeversolar/ @kvanzuijlen
|
||||
/homeassistant/components/zeversolar/ @kvanzuijlen @mhuiskes
|
||||
/tests/components/zeversolar/ @kvanzuijlen @mhuiskes
|
||||
/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
|
||||
/homeassistant/components/zimi/ @markhannon
|
||||
|
||||
@@ -73,10 +73,12 @@ async def auth_manager_from_config(
|
||||
provider_hash[key] = provider
|
||||
|
||||
if isinstance(provider, HassAuthProvider):
|
||||
# Can be removed in 2026.7 with the legacy mode of homeassistant auth provider
|
||||
# We need to initialize the provider to create the repair if needed as otherwise
|
||||
# the provider will be initialized on first use, which could be rare as users
|
||||
# don't frequently change auth settings
|
||||
# Can be removed in 2026.7 with the legacy mode of
|
||||
# homeassistant auth provider.
|
||||
# We need to initialize the provider to create the repair
|
||||
# if needed as otherwise the provider will be initialized
|
||||
# on first use, which could be rare as users don't
|
||||
# frequently change auth settings
|
||||
await provider.async_initialize()
|
||||
|
||||
if module_configs:
|
||||
|
||||
@@ -120,9 +120,10 @@ class Data:
|
||||
if self.normalize_username(username, force_normalize=True) != username:
|
||||
logging.getLogger(__name__).warning(
|
||||
(
|
||||
"Home Assistant auth provider is running in legacy mode "
|
||||
"because we detected usernames that are normalized (lowercase and without spaces)."
|
||||
" Please change the username: '%s'."
|
||||
"Home Assistant auth provider is running in"
|
||||
" legacy mode because we detected usernames"
|
||||
" that are normalized (lowercase and without"
|
||||
" spaces). Please change the username: '%s'."
|
||||
),
|
||||
username,
|
||||
)
|
||||
@@ -139,7 +140,9 @@ class Data:
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="homeassistant_provider_not_normalized_usernames",
|
||||
translation_placeholders={
|
||||
"usernames": f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
|
||||
"usernames": (
|
||||
f'- "{'"\n- "'.join(sorted(not_normalized_usernames))}"'
|
||||
)
|
||||
},
|
||||
learn_more_url="homeassistant://config/users",
|
||||
)
|
||||
|
||||
@@ -60,7 +60,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent |
|
||||
|
||||
|
||||
def _clear_configuration_directory(config_dir: Path, keep: Iterable[str]) -> None:
|
||||
"""Delete all files and directories in the config directory except entries in the keep list."""
|
||||
"""Delete all files and directories in the config dir.
|
||||
|
||||
Entries in the keep list are preserved.
|
||||
"""
|
||||
keep_paths = [config_dir.joinpath(path) for path in keep]
|
||||
entries_to_remove = sorted(
|
||||
entry for entry in config_dir.iterdir() if entry not in keep_paths
|
||||
@@ -101,7 +104,8 @@ def _extract_backup(
|
||||
)
|
||||
) > HA_VERSION:
|
||||
raise ValueError(
|
||||
f"You need at least Home Assistant version {backup_meta_version} to restore this backup"
|
||||
f"You need at least Home Assistant version"
|
||||
f" {backup_meta_version} to restore this backup"
|
||||
)
|
||||
|
||||
with securetar.SecureTarFile(
|
||||
|
||||
@@ -17,7 +17,8 @@ from time import monotonic
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
# Import cryptography early since import openssl is not thread-safe
|
||||
# _frozen_importlib._DeadlockError: deadlock detected by _ModuleLock('cryptography.hazmat.backends.openssl.backend')
|
||||
# _frozen_importlib._DeadlockError: deadlock detected by
|
||||
# _ModuleLock('cryptography.hazmat.backends.openssl.backend')
|
||||
import cryptography.hazmat.backends.openssl.backend # noqa: F401
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
@@ -165,10 +166,14 @@ FRONTEND_INTEGRATIONS = {
|
||||
# visible in frontend
|
||||
"frontend",
|
||||
}
|
||||
# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
|
||||
# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
|
||||
# Stage 0 is divided into substages. Each substage has a name,
|
||||
# a set of integrations and a timeout.
|
||||
# The substage containing recorder should have no timeout, as it
|
||||
# could cancel a database migration.
|
||||
# Recorder freezes "recorder" timeout during a migration, but it
|
||||
# does not freeze other timeouts.
|
||||
# If we add timeouts to the frontend substages, we should make sure
|
||||
# they don't apply in recovery mode.
|
||||
STAGE_0_INTEGRATIONS = (
|
||||
# Load logging and http deps as soon as possible
|
||||
("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "marantz",
|
||||
"name": "Marantz",
|
||||
"integrations": ["marantz", "marantz_infrared"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "samsung",
|
||||
"name": "Samsung",
|
||||
"integrations": ["familyhub", "samsungtv", "syncthru"]
|
||||
"integrations": ["familyhub", "samsung_infrared", "samsungtv", "syncthru"]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ def _change_setting(call: ServiceCall) -> None:
|
||||
|
||||
try:
|
||||
_get_abode_system(call.hass).abode.set_setting(setting, value)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except AbodeException as ex:
|
||||
LOGGER.warning(ex)
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
"requirements": ["serialx==1.8.0"]
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@
|
||||
"exceptions": {
|
||||
"config_entry_not_loaded": {
|
||||
"message": "Config entry not loaded."
|
||||
},
|
||||
"error_while_turn_off": {
|
||||
"message": "An error occurred while turning off AdGuard Home switch."
|
||||
},
|
||||
"error_while_turn_on": {
|
||||
"message": "An error occurred while turning on AdGuard Home switch."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -9,10 +9,11 @@ from adguardhome import AdGuardHome, AdGuardHomeError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AdGuardConfigEntry, AdGuardData
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DOMAIN
|
||||
from .entity import AdGuardHomeEntity
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
@@ -116,17 +117,23 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity):
|
||||
"""Turn off the switch."""
|
||||
try:
|
||||
await self.entity_description.turn_off_fn(self.adguard)()
|
||||
except AdGuardHomeError:
|
||||
LOGGER.error("An error occurred while turning off AdGuard Home switch")
|
||||
except AdGuardHomeError as err:
|
||||
self._attr_available = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_while_turn_off",
|
||||
) from err
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the switch."""
|
||||
try:
|
||||
await self.entity_description.turn_on_fn(self.adguard)()
|
||||
except AdGuardHomeError:
|
||||
LOGGER.error("An error occurred while turning on AdGuard Home switch")
|
||||
except AdGuardHomeError as err:
|
||||
self._attr_available = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_while_turn_on",
|
||||
) from err
|
||||
|
||||
async def _adguard_update(self) -> None:
|
||||
"""Update AdGuard Home entity."""
|
||||
|
||||
@@ -106,7 +106,8 @@ class AdsLight(AdsEntity, LightEntity):
|
||||
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
|
||||
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
|
||||
|
||||
# Set color temperature range (static config values take precedence over defaults)
|
||||
# Set color temperature range
|
||||
# (static config values take precedence over defaults)
|
||||
if ads_var_color_temp_kelvin is not None:
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
min_color_temp_kelvin
|
||||
|
||||
@@ -171,7 +171,8 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the current target temperature."""
|
||||
# If the system is in MyZone mode, and a zone is set, return that temperature instead.
|
||||
# If the system is in MyZone mode, and a zone is set,
|
||||
# return that temperature instead.
|
||||
if self._myzone and self.preset_mode == ADVANTAGE_AIR_MYZONE:
|
||||
return self._myzone["setTemp"]
|
||||
return self._ac["setTemp"]
|
||||
@@ -296,7 +297,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
|
||||
"""Return the HVAC action.
|
||||
|
||||
Inherits from master AC if zone is open but idle if air
|
||||
is <= 5%.
|
||||
"""
|
||||
if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
|
||||
return HVACAction.OFF
|
||||
master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
|
||||
|
||||
@@ -59,6 +59,8 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=home-assistant-config-flow-name-field
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
|
||||
vol.Optional(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
|
||||
@@ -56,6 +56,7 @@ async def async_setup_entry(
|
||||
)
|
||||
async_dispatcher_send(hass, UPDATE_TOPIC)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_ADD_TRACKING,
|
||||
@@ -71,6 +72,7 @@ async def async_setup_entry(
|
||||
)
|
||||
async_dispatcher_send(hass, UPDATE_TOPIC)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_REMOVE_TRACKING,
|
||||
|
||||
@@ -68,20 +68,24 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
"co_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.CO)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
"ozone_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
@@ -92,14 +96,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
),
|
||||
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
"voc_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
MassVolumeConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
@@ -110,44 +116,60 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
),
|
||||
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
"voc_ratio_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
|
||||
)
|
||||
},
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
UnitlessRatioConverter,
|
||||
)
|
||||
),
|
||||
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
),
|
||||
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_MONOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
"no_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenMonoxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
),
|
||||
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.NITROGEN_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
"no2_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: DomainSpec(
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
|
||||
)
|
||||
},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
NitrogenDioxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
),
|
||||
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
"so2_crossed_threshold": (
|
||||
make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.SULPHUR_DIOXIDE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
SulphurDioxideConcentrationConverter,
|
||||
)
|
||||
),
|
||||
# Numerical sensor triggers without unit conversion (single-unit device classes)
|
||||
"co2_changed": make_entity_numerical_state_changed_trigger(
|
||||
|
||||
@@ -184,7 +184,8 @@ async def async_setup_entry(
|
||||
(
|
||||
AirlySensor(coordinator, name, description)
|
||||
for description in SENSOR_TYPES
|
||||
# When we use the nearest method, we are not sure which sensors are available
|
||||
# When we use the nearest method, we are not sure
|
||||
# which sensors are available
|
||||
if coordinator.data.get(description.key)
|
||||
),
|
||||
False,
|
||||
|
||||
@@ -75,7 +75,9 @@ def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]:
|
||||
ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION],
|
||||
ATTR_LEVEL: data[ATTR_API_AQI_LEVEL],
|
||||
ATTR_TIME: parser.parse(
|
||||
f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}",
|
||||
f"{data[ATTR_API_REPORT_DATE]} "
|
||||
f"{data[ATTR_API_REPORT_HOUR]}:00 "
|
||||
f"{data[ATTR_API_REPORT_TZ]}",
|
||||
tzinfos=US_TZ_OFFSETS,
|
||||
).isoformat(),
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ class AirobotButton(AirobotEntity, ButtonEntity):
|
||||
"""Handle the button press."""
|
||||
try:
|
||||
await self.entity_description.press_fn(self.coordinator)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except AirobotConnectionError, AirobotTimeoutError:
|
||||
# Connection errors during reboot are expected as device restarts
|
||||
pass
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import AirOSDataBaseClass
|
||||
|
||||
@@ -20,13 +19,10 @@ from .entity import AirOSEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSBinarySensorEntityDescription(
|
||||
class AirOSBinarySensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
|
||||
BinarySensorEntityDescription,
|
||||
Generic[AirOSDataModel],
|
||||
):
|
||||
"""Describe an AirOS binary sensor."""
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from airos.data import (
|
||||
AirOSDataBaseClass,
|
||||
@@ -41,11 +40,11 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
|
||||
class AirOSSensorEntityDescription[AirOSDataModel: AirOSDataBaseClass](
|
||||
SensorEntityDescription
|
||||
):
|
||||
"""Describe an AirOS sensor."""
|
||||
|
||||
value_fn: Callable[[AirOSDataModel], StateType]
|
||||
|
||||
@@ -54,7 +54,8 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
"""Fetch the data from the device."""
|
||||
if "name" not in self.device_info:
|
||||
_LOGGER.debug(
|
||||
"'name' not found in AirQCoordinator.device_info, fetching from the device"
|
||||
"'name' not found in AirQCoordinator.device_info,"
|
||||
" fetching from the device"
|
||||
)
|
||||
info = await self.airq.fetch_device_info()
|
||||
self.device_info.update(
|
||||
|
||||
@@ -158,7 +158,8 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity):
|
||||
await self._airtouch.SetCoolingModeForAc(
|
||||
self._ac_number, HA_STATE_TO_AT[hvac_mode]
|
||||
)
|
||||
# in case it isn't already, unless the HVAC mode was off, then the ac should be on
|
||||
# in case it isn't already, unless the HVAC mode was off,
|
||||
# then the ac should be on
|
||||
await self.async_turn_on()
|
||||
self._unit = self._airtouch.GetAcs()[self._ac_number]
|
||||
_LOGGER.debug("Setting operation mode of %s to %s", self._ac_number, hvac_mode)
|
||||
@@ -246,7 +247,8 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac target hvac state."""
|
||||
# there are other power states that aren't 'on' but still count as on (eg. 'Turbo')
|
||||
# there are other power states that aren't 'on' but still
|
||||
# count as on (eg. 'Turbo')
|
||||
is_off = self._unit.PowerState == "Off"
|
||||
if is_off:
|
||||
return HVACMode.OFF
|
||||
|
||||
@@ -178,7 +178,8 @@ class Airtouch5AC(Airtouch5ClimateEntity):
|
||||
if ability.supports_fan_speed_intelligent_auto:
|
||||
self._attr_fan_modes.append(FAN_INTELLIGENT_AUTO)
|
||||
|
||||
# We can have different setpoints for heat cool, we expose the lowest low and highest high
|
||||
# We can have different setpoints for heat cool,
|
||||
# we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(
|
||||
ability.min_cool_set_point, ability.min_heat_set_point
|
||||
)
|
||||
@@ -290,7 +291,8 @@ class Airtouch5Zone(Airtouch5ClimateEntity):
|
||||
manufacturer="Polyaire",
|
||||
model="AirTouch 5",
|
||||
)
|
||||
# We can have different setpoints for heat and cool, we expose the lowest low and highest high
|
||||
# We can have different setpoints for heat and cool,
|
||||
# we expose the lowest low and highest high
|
||||
self._attr_min_temp = min(ac.min_cool_set_point, ac.min_heat_set_point)
|
||||
self._attr_max_temp = max(ac.max_cool_set_point, ac.max_heat_set_point)
|
||||
|
||||
|
||||
@@ -34,8 +34,9 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors = {"base": "cannot_connect"}
|
||||
else:
|
||||
# Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed
|
||||
# pylint: disable-next=hass-unique-id-ip-based
|
||||
# Uses the host/IP value from CONF_HOST as unique ID,
|
||||
# which is no longer allowed
|
||||
# pylint: disable-next=home-assistant-unique-id-ip-based
|
||||
await self.async_set_unique_id(user_input[CONF_HOST])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -75,7 +75,7 @@ def async_get_cloud_api_update_interval(
|
||||
def async_get_cloud_coordinators_by_api_key(
|
||||
hass: HomeAssistant, api_key: str
|
||||
) -> list[AirVisualDataUpdateCoordinator]:
|
||||
"""Get all AirVisualDataUpdateCoordinator objects related to a particular API key."""
|
||||
"""Get all coordinators related to a particular API key."""
|
||||
return [
|
||||
entry.runtime_data
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
@@ -24,7 +24,7 @@ class AsyncConfigFlowAuth(Auth):
|
||||
|
||||
|
||||
class AsyncConfigEntryAuth(Auth):
|
||||
"""Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry."""
|
||||
"""Provide Aladdin Connect Genie auth tied to an OAuth2 config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -354,8 +354,9 @@ def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]:
|
||||
def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert necessary keys to int.
|
||||
|
||||
Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as
|
||||
strings and then convert them to ints.
|
||||
Since ConfigFlow inputs of type int cannot default to an empty
|
||||
string, we collect the values below as strings and then convert
|
||||
them to ints.
|
||||
"""
|
||||
|
||||
for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN):
|
||||
|
||||
@@ -1255,7 +1255,10 @@ async def async_api_set_mode(
|
||||
service = water_heater.SERVICE_SET_OPERATION_MODE
|
||||
data[water_heater.ATTR_OPERATION_MODE] = operation_mode
|
||||
else:
|
||||
msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'"
|
||||
msg = (
|
||||
f"Entity '{entity.entity_id}' does not support"
|
||||
f" Operation mode '{operation_mode}'"
|
||||
)
|
||||
raise AlexaInvalidValueError(msg)
|
||||
|
||||
# Cover Position
|
||||
|
||||
@@ -224,7 +224,8 @@ def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]:
|
||||
resolved_data["id"] = possible_values[0]["id"]
|
||||
|
||||
# If there is only one match use the resolved value, otherwise the
|
||||
# resolution cannot be determined, so use the spoken slot value and empty string as id
|
||||
# resolution cannot be determined, so use the spoken slot
|
||||
# value and empty string as id
|
||||
if len(possible_values) == 1:
|
||||
resolved_data["value"] = possible_values[0]["name"]
|
||||
else:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
@@ -11,7 +12,12 @@ from uuid import uuid4
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components import event
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
|
||||
from homeassistant.const import (
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -51,6 +57,25 @@ DEFAULT_TIMEOUT = 10
|
||||
TO_REDACT = {"correlationToken", "token"}
|
||||
|
||||
|
||||
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
|
||||
"""Check if doorbell event timestamp is valid."""
|
||||
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(event_state)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unable to parse ISO timestamp from state for %s. Got %s",
|
||||
entity_id,
|
||||
event_state,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AlexaDirective:
|
||||
"""An incoming Alexa directive."""
|
||||
|
||||
@@ -315,9 +340,17 @@ async def async_enable_proactive_mode(
|
||||
|
||||
if should_doorbell:
|
||||
old_state = data["old_state"]
|
||||
if new_state.domain == event.DOMAIN or (
|
||||
if (
|
||||
new_state.domain == event.DOMAIN
|
||||
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
|
||||
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
|
||||
and (old_state is None or old_state.state != new_state.state)
|
||||
) or (
|
||||
new_state.state == STATE_ON
|
||||
and (old_state is None or old_state.state != STATE_ON)
|
||||
and (
|
||||
old_state is None
|
||||
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
|
||||
)
|
||||
):
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
|
||||
@@ -44,6 +44,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if CONF_SITE in entry.data:
|
||||
# Site in data (wrong place), just move to login data
|
||||
|
||||
@@ -27,9 +27,6 @@ COUNTRY_DOMAINS = {
|
||||
"za": "co.za",
|
||||
}
|
||||
|
||||
CATEGORY_SENSORS = "sensors"
|
||||
CATEGORY_NOTIFICATIONS = "notifications"
|
||||
|
||||
# Map service translation keys to Alexa API
|
||||
INFO_SKILLS_MAPPING = {
|
||||
"calendar_today": "Alexa.Calendar.PlayToday",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
"requirements": ["aioamazondevices==13.7.0"]
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
@@ -38,30 +37,44 @@ from .utils import async_remove_unsupported_notification_sensors
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
type ValueFn = Callable[
|
||||
[AmazonDevice, str, AmazonDevicesCoordinator], StateType | datetime
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSensorEntityDescription(SensorEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
class AmazonBaseEntityDescription(SensorEntityDescription):
|
||||
"""Shared Amazon Devices entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
)
|
||||
value_fn: ValueFn
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonSensorEntityDescription(AmazonBaseEntityDescription):
|
||||
"""Amazon Devices sensor entity description."""
|
||||
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
and (sensor := device.sensors.get(key)) is not None
|
||||
and sensor.error is False
|
||||
)
|
||||
category: str = CATEGORY_SENSORS
|
||||
value_fn: ValueFn = lambda device, key, _: device.sensors[key].value
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AmazonNotificationEntityDescription(SensorEntityDescription):
|
||||
class AmazonNotificationEntityDescription(AmazonBaseEntityDescription):
|
||||
"""Amazon Devices notification entity description."""
|
||||
|
||||
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
|
||||
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
|
||||
device.online
|
||||
and (notification := device.notifications.get(key)) is not None
|
||||
and notification.next_occurrence is not None
|
||||
)
|
||||
category: str = CATEGORY_NOTIFICATIONS
|
||||
value_fn: ValueFn = lambda device, key, _: device.notifications[key].next_occurrence
|
||||
|
||||
|
||||
SENSORS: Final = (
|
||||
@@ -193,11 +206,11 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state of the sensor."""
|
||||
# Sensors
|
||||
if self.entity_description.category == CATEGORY_SENSORS:
|
||||
return self.device.sensors[self.entity_description.key].value
|
||||
# Notifications
|
||||
return self.device.notifications[self.entity_description.key].next_occurrence
|
||||
return self.entity_description.value_fn(
|
||||
self.device,
|
||||
self.entity_description.key,
|
||||
self.coordinator,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
|
||||
@@ -21,7 +21,8 @@ API_URL = "https://app.amber.com.au/developers"
|
||||
|
||||
def generate_site_selector_name(site: Site) -> str:
|
||||
"""Generate the name to show in the site drop down in the configuration flow."""
|
||||
# For some reason the generated API key returns this as any, not a string. Thanks pydantic
|
||||
# For some reason the generated API key returns this as any,
|
||||
# not a string. Thanks pydantic
|
||||
nmi = str(site.nmi)
|
||||
if site.status == SiteStatus.CLOSED:
|
||||
if site.closed_on is None:
|
||||
|
||||
@@ -48,7 +48,7 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
|
||||
|
||||
|
||||
class AmberUpdateCoordinator(DataUpdateCoordinator):
|
||||
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
|
||||
"""Coordinator in charge of downloading site data for all sensors."""
|
||||
|
||||
config_entry: AmberConfigEntry
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ DESCRIPTOR_MAP: dict[str, str] = {
|
||||
|
||||
|
||||
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
|
||||
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
|
||||
"""Return the snake case versions of descriptor names.
|
||||
|
||||
Returns None if the name is not recognized.
|
||||
"""
|
||||
if descriptor in DESCRIPTOR_MAP:
|
||||
return DESCRIPTOR_MAP[descriptor]
|
||||
return None
|
||||
|
||||
@@ -26,4 +26,5 @@ def get_station_name(station: dict[str, Any]) -> str:
|
||||
.get(API_STATION_LOCATION)
|
||||
)
|
||||
station_type = station.get(API_LAST_DATA, {}).get(API_STATION_TYPE)
|
||||
return f"{location}{'' if location is None or station_type is None else ' '}{station_type}"
|
||||
separator = "" if location is None or station_type is None else " "
|
||||
return f"{location}{separator}{station_type}"
|
||||
|
||||
@@ -192,7 +192,8 @@ class AmcrestBinarySensor(BinarySensorEntity):
|
||||
|
||||
if self._api.available:
|
||||
# Send a command to the camera to test if we can still communicate with it.
|
||||
# Override of Http.async_command() in __init__.py will set self._api.available
|
||||
# Override of Http.async_command() in __init__.py will
|
||||
# set self._api.available
|
||||
# accordingly.
|
||||
with suppress(AmcrestError):
|
||||
await self._api.async_current_time
|
||||
|
||||
@@ -461,7 +461,8 @@ class AmcrestCam(Camera):
|
||||
|
||||
async def _async_set_recording(self, enable: bool) -> None:
|
||||
rec_mode = {"Automatic": 0, "Manual": 1}
|
||||
# The property has a str type, but setter has int type, which causes mypy confusion
|
||||
# The property has a str type, but setter has int type,
|
||||
# which causes mypy confusion
|
||||
await self._api.async_set_record_mode(
|
||||
rec_mode["Manual" if enable else "Automatic"]
|
||||
)
|
||||
@@ -479,7 +480,8 @@ class AmcrestCam(Camera):
|
||||
return await self._api.async_is_motion_detector_on()
|
||||
|
||||
async def _async_set_motion_detection(self, enable: bool) -> None:
|
||||
# The property has a str type, but setter has bool type, which causes mypy confusion
|
||||
# The property has a str type, but setter has bool type,
|
||||
# which causes mypy confusion
|
||||
await self._api.async_set_motion_detection(enable)
|
||||
|
||||
async def _async_enable_motion_detection(self, enable: bool) -> None:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
import contextlib
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from datetime import datetime
|
||||
import random
|
||||
@@ -297,20 +298,24 @@ class Analytics:
|
||||
if stored:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if (
|
||||
self.supervisor
|
||||
and (supervisor_info := hassio.get_supervisor_info(self._hass)) is not None
|
||||
):
|
||||
if not self.onboarded:
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = True
|
||||
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
if supervisor_info[ATTR_DIAGNOSTICS] and not self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = True
|
||||
elif not supervisor_info[ATTR_DIAGNOSTICS] and self.preferences.get(
|
||||
ATTR_DIAGNOSTICS, False
|
||||
):
|
||||
self._data.preferences[ATTR_DIAGNOSTICS] = False
|
||||
|
||||
async def _save(self) -> None:
|
||||
"""Save data."""
|
||||
@@ -344,9 +349,14 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
operating_system_info = hassio.get_os_info(hass) or {}
|
||||
addons_info = hassio.get_addons_info(hass) or {}
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
operating_system_info = hassio.get_os_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
addons_info = hassio.get_addons_info(hass)
|
||||
|
||||
system_info = await async_get_system_info(hass)
|
||||
integrations = []
|
||||
@@ -419,7 +429,7 @@ class Analytics:
|
||||
|
||||
integrations.append(integration.domain)
|
||||
|
||||
if addons_info is not None:
|
||||
if addons_info:
|
||||
supervisor_client = hassio.get_supervisor_client(hass)
|
||||
installed_addons = await asyncio.gather(
|
||||
*(supervisor_client.addons.addon_info(slug) for slug in addons_info)
|
||||
@@ -602,7 +612,8 @@ class Analytics:
|
||||
|
||||
else:
|
||||
LOGGER.warning(
|
||||
"Unexpected status code %s when submitting snapshot analytics to %s",
|
||||
"Unexpected status code %s when submitting"
|
||||
" snapshot analytics to %s",
|
||||
response.status,
|
||||
url,
|
||||
)
|
||||
@@ -804,7 +815,8 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
"Calling async_modify_analytics for integration"
|
||||
" '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
@@ -818,7 +830,8 @@ async def _async_snapshot_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
# instead we refer to devices by
|
||||
# (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
ObjectSelector,
|
||||
@@ -32,6 +33,7 @@ from .const import (
|
||||
CONF_APPS,
|
||||
CONF_EXCLUDE_UNNAMED_APPS,
|
||||
CONF_GET_SOURCES,
|
||||
CONF_MORE_OPTIONS,
|
||||
CONF_SCREENCAP_INTERVAL,
|
||||
CONF_STATE_DETECTION_RULES,
|
||||
CONF_TURN_OFF_COMMAND,
|
||||
@@ -97,20 +99,22 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_ADBKEY): str,
|
||||
vol.Optional(CONF_ADB_SERVER_IP): str,
|
||||
vol.Optional(
|
||||
CONF_ADB_SERVER_PORT,
|
||||
default=DEFAULT_ADB_SERVER_PORT,
|
||||
): cv.port,
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if self.show_advanced_options:
|
||||
data_schema = data_schema.extend(
|
||||
{
|
||||
vol.Optional(CONF_ADBKEY): str,
|
||||
vol.Optional(CONF_ADB_SERVER_IP): str,
|
||||
vol.Required(
|
||||
CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT
|
||||
): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
@@ -155,6 +159,10 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
error = None
|
||||
|
||||
if user_input is not None:
|
||||
user_input = user_input.copy()
|
||||
more_options = user_input.pop(CONF_MORE_OPTIONS, {})
|
||||
user_input.update(more_options)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
adb_key = user_input.get(CONF_ADBKEY)
|
||||
if CONF_ADB_SERVER_IP in user_input:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
DOMAIN = "androidtv"
|
||||
|
||||
CONF_ADB_SERVER_IP = "adb_server_ip"
|
||||
CONF_MORE_OPTIONS = "more_options"
|
||||
CONF_ADB_SERVER_PORT = "adb_server_port"
|
||||
CONF_ADBKEY = "adbkey"
|
||||
CONF_APPS = "apps"
|
||||
|
||||
@@ -94,10 +94,9 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R](
|
||||
# it doesn't happen over and over again.
|
||||
if self.available:
|
||||
_LOGGER.error(
|
||||
(
|
||||
"Unexpected exception executing an ADB command. ADB connection"
|
||||
" re-establishing attempt in the next update. Error: %s"
|
||||
),
|
||||
"Unexpected exception executing an ADB"
|
||||
" command. ADB connection re-establishing"
|
||||
" attempt in the next update. Error: %s",
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def service_download(self, device_path: str, local_path: str) -> None:
|
||||
"""Download a file from your Android / Fire TV device to your Home Assistant instance."""
|
||||
"""Download a file from your Android / Fire TV device."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
@@ -290,7 +290,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity):
|
||||
|
||||
@adb_decorator()
|
||||
async def service_upload(self, device_path: str, local_path: str) -> None:
|
||||
"""Upload a file from your Home Assistant instance to an Android / Fire TV device."""
|
||||
"""Upload a file to an Android / Fire TV device."""
|
||||
if not self.hass.config.is_allowed_path(local_path):
|
||||
_LOGGER.warning("'%s' is not secure to load data from!", local_path)
|
||||
return
|
||||
|
||||
@@ -14,12 +14,19 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
|
||||
"adb_server_port": "Port of the ADB server",
|
||||
"adbkey": "Path to your ADB key file (leave empty to auto generate)",
|
||||
"device_class": "The type of device",
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"sections": {
|
||||
"more_options": {
|
||||
"data": {
|
||||
"adb_server_ip": "IP address of the ADB server (leave empty to not use)",
|
||||
"adb_server_port": "Port of the ADB server",
|
||||
"adbkey": "Path to your ADB key file (leave empty to auto generate)"
|
||||
},
|
||||
"name": "More options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,9 @@ async def async_setup_entry(
|
||||
# The Android TV is hard reset or the certificate and key files were deleted.
|
||||
raise ConfigEntryAuthFailed from exc
|
||||
except (CannotConnect, ConnectionClosed, TimeoutError) as exc:
|
||||
# The Android TV is network unreachable. Raise exception and let Home Assistant retry
|
||||
# later. If device gets a new IP address the zeroconf flow will update the config.
|
||||
# The Android TV is network unreachable. Raise exception and
|
||||
# let Home Assistant retry later. If device gets a new IP
|
||||
# address the zeroconf flow will update the config.
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
def reauth_needed() -> None:
|
||||
|
||||
@@ -107,7 +107,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _async_start_pair(self) -> ConfigFlowResult:
|
||||
"""Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen."""
|
||||
"""Start pairing with the Android TV.
|
||||
|
||||
Navigate to the pair flow to enter the PIN shown on screen.
|
||||
"""
|
||||
self.api = create_api(self.hass, self.host, enable_ime=False)
|
||||
await self.api.async_generate_cert_if_missing()
|
||||
await self.api.async_start_pairing()
|
||||
@@ -135,9 +138,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_start_pair()
|
||||
except CannotConnect, ConnectionClosed:
|
||||
# Device doesn't respond to the specified host. Abort.
|
||||
# If we are in the user flow we could go back to the user step to allow
|
||||
# them to enter a new IP address but we cannot do that for the zeroconf
|
||||
# flow. Simpler to abort for both flows.
|
||||
# If we are in the user flow we could go back
|
||||
# to the user step to allow them to enter a
|
||||
# new IP address but we cannot do that for the
|
||||
# zeroconf flow. Simpler to abort for both.
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
else:
|
||||
if self.source == SOURCE_REAUTH:
|
||||
|
||||
@@ -42,7 +42,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
|
||||
@callback
|
||||
def _is_available_updated(self, is_available: bool) -> None:
|
||||
"""Update the state when the device is ready to receive commands or is unavailable."""
|
||||
"""Update the state when the device is ready or unavailable."""
|
||||
self._attr_available = is_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -65,7 +65,8 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None:
|
||||
"""Send a key press to Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
This does not block; it buffers the data and arranges
|
||||
for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_key_command(key_code, direction)
|
||||
@@ -77,7 +78,8 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
def _send_launch_app_command(self, app_link: str) -> None:
|
||||
"""Launch an app on Android TV.
|
||||
|
||||
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
||||
This does not block; it buffers the data and arranges
|
||||
for it to be sent out asynchronously.
|
||||
"""
|
||||
try:
|
||||
self._api.send_launch_app_command(app_link)
|
||||
|
||||
@@ -95,8 +95,10 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
if not meter.readings or len(meter.readings) == 0:
|
||||
_LOGGER.debug("No recent usage statistics found, skipping update")
|
||||
continue
|
||||
# Anglian Water stats are hourly, the read_at time is the time that the meter took the reading
|
||||
# We remove 1 hour from this so that the data is shown in the correct hour on the dashboards
|
||||
# Anglian Water stats are hourly, the read_at time
|
||||
# is the time that the meter took the reading.
|
||||
# We remove 1 hour from this so that the data is
|
||||
# shown in the correct hour on the dashboards
|
||||
parsed_read_at = dt_util.parse_datetime(meter.readings[0]["read_at"])
|
||||
if not parsed_read_at:
|
||||
_LOGGER.debug(
|
||||
@@ -130,8 +132,9 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
if not stats or not stats.get(usage_statistic_id):
|
||||
_LOGGER.debug(
|
||||
"Could not find existing statistics during period lookup for %s, "
|
||||
"falling back to last stored statistic",
|
||||
"Could not find existing statistics during"
|
||||
" period lookup for %s, falling back to"
|
||||
" last stored statistic",
|
||||
usage_statistic_id,
|
||||
)
|
||||
allow_update_last_stored_hour = True
|
||||
|
||||
@@ -43,7 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> boo
|
||||
except NoDevicesFound as err:
|
||||
# Can later setup successfully and spawn a repair.
|
||||
raise ConfigEntryNotReady(
|
||||
"No devices were found on the websocket, perhaps you don't have any devices on this account?"
|
||||
"No devices were found on the websocket, perhaps you"
|
||||
" don't have any devices on this account?"
|
||||
) from err
|
||||
except WebsocketFailure as err:
|
||||
raise ConfigEntryNotReady("Failed connecting to the websocket.") from err
|
||||
|
||||
@@ -546,7 +546,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
description="Free text input for the city, e.g. `San Francisco`",
|
||||
description=(
|
||||
"Free text input for the city, e.g. `San Francisco`"
|
||||
),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
|
||||
@@ -34,7 +34,7 @@ def model_alias(model_id: str) -> str:
|
||||
|
||||
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
|
||||
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
|
||||
"""Coordinator using different intervals after success and failure."""
|
||||
|
||||
client: anthropic.AsyncAnthropic
|
||||
|
||||
|
||||
@@ -452,7 +452,8 @@ def _convert_content( # noqa: C901
|
||||
# If there is only one text block, simplify the content to a string
|
||||
messages[-1]["content"] = messages[-1]["content"][0]["text"]
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as it's passed to the API as the prompt
|
||||
# Note: We don't pass SystemContent here as it's
|
||||
# passed to the API as the prompt
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unexpected_chat_log_content",
|
||||
@@ -467,7 +468,8 @@ class AnthropicDeltaStream:
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock
|
||||
(if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
@@ -646,7 +648,8 @@ class AnthropicDeltaStream:
|
||||
|
||||
def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None:
|
||||
"""Handle TextBlock."""
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
if ( # Do not start a new assistant content just for
|
||||
# citations, concatenate consecutive blocks instead.
|
||||
self._first_block
|
||||
or (
|
||||
not self._content_details.has_citations()
|
||||
@@ -977,7 +980,8 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
]
|
||||
|
||||
if options[CONF_CODE_EXECUTION]:
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
# The `web_search_20260209` tool automatically enables
|
||||
# `code_execution_20260120` tool
|
||||
if (
|
||||
not self.model_info.capabilities
|
||||
or not self.model_info.capabilities.code_execution.supported
|
||||
@@ -1159,7 +1163,8 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
)
|
||||
cast(list[MessageParam], model_args["messages"]).extend(new_messages)
|
||||
except anthropic.AuthenticationError as err:
|
||||
# Trigger coordinator to confirm the auth failure and trigger the reauth flow.
|
||||
# Trigger coordinator to confirm the auth failure
|
||||
# and trigger the reauth flow.
|
||||
await coordinator.async_request_refresh()
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -45,7 +45,10 @@ class AOSmithHotWaterPlusSelectEntity(AOSmithStatusEntity, SelectEntity):
|
||||
|
||||
@property
|
||||
def suggested_object_id(self) -> str | None:
|
||||
"""Override the suggested object id to make '+' get converted to 'plus' in the entity id."""
|
||||
"""Override the suggested object id.
|
||||
|
||||
Makes '+' get converted to 'plus' in the entity id.
|
||||
"""
|
||||
return "hot_water_plus_level"
|
||||
|
||||
@property
|
||||
|
||||
@@ -54,7 +54,8 @@ class OnlineStatus(APCUPSdEntity, BinarySensorEntity):
|
||||
"""Returns true if the UPS is online."""
|
||||
# Check if ONLINE bit is set in STATFLAG.
|
||||
key = self.entity_description.key.upper()
|
||||
# The daemon could either report just a hex ("0x05000008"), or a hex with a "Status Flag"
|
||||
# The daemon could either report just a hex
|
||||
# ("0x05000008"), or a hex with a "Status Flag"
|
||||
# suffix ("0x05000008 Status Flag") in older versions.
|
||||
# Here we trim the suffix if it exists to support both.
|
||||
flag = self.coordinator.data[key].removesuffix(" Status Flag")
|
||||
|
||||
@@ -8,7 +8,8 @@ CONNECTION_TIMEOUT: int = 10
|
||||
# Field name of last self test retrieved from apcupsd.
|
||||
LAST_S_TEST: Final = "laststest"
|
||||
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd, lower-cased) to their deprecation
|
||||
# Mapping of deprecated sensor keys (as reported by apcupsd,
|
||||
# lower-cased) to their deprecation
|
||||
# repair issue translation keys.
|
||||
DEPRECATED_SENSORS: Final = {
|
||||
"apc": "apc_deprecated",
|
||||
|
||||
@@ -27,7 +27,7 @@ type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator]
|
||||
|
||||
|
||||
class APCUPSdData(dict[str, str]):
|
||||
"""Store data about an APCUPSd and provide a few helper methods for easier accesses."""
|
||||
"""Store data about an APCUPSd and provide helper methods."""
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
@@ -45,8 +45,9 @@ class APCUPSdData(dict[str, str]):
|
||||
def serial_no(self) -> str | None:
|
||||
"""Return the unique serial number of the UPS, if available."""
|
||||
sn = self.get("SERIALNO")
|
||||
# We had user reports that some UPS models simply return "Blank" as serial number, in
|
||||
# which case we fall back to `None` to indicate that it is actually not available.
|
||||
# We had user reports that some UPS models simply return
|
||||
# "Blank" as serial number, in which case we fall back to
|
||||
# `None` to indicate that it is actually not available.
|
||||
return None if sn == "Blank" else sn
|
||||
|
||||
|
||||
@@ -85,7 +86,11 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
|
||||
|
||||
@property
|
||||
def unique_device_id(self) -> str:
|
||||
"""Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
|
||||
"""Return a unique ID of the device.
|
||||
|
||||
Uses the serial number if available, otherwise the
|
||||
config entry ID.
|
||||
"""
|
||||
return self.data.serial_no or self.config_entry.entry_id
|
||||
|
||||
@property
|
||||
|
||||
@@ -473,13 +473,16 @@ async def async_setup_entry(
|
||||
|
||||
entities = []
|
||||
|
||||
# "laststest" is a special sensor that only appears when the APC UPS daemon has done a
|
||||
# periodical (or manual) self test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it would ever be available. Here we
|
||||
# add it anyway and mark it as unknown initially.
|
||||
# "laststest" is a special sensor that only appears when
|
||||
# the APC UPS daemon has done a periodical (or manual) self
|
||||
# test since last daemon restart. It might not be available
|
||||
# when we set up the integration, and we do not know if it
|
||||
# would ever be available. Here we add it anyway and mark it
|
||||
# as unknown initially.
|
||||
#
|
||||
# We also sort the resources to ensure the order of entities created is deterministic since
|
||||
# "APCMODEL" and "MODEL" resources map to the same "Model" name.
|
||||
# We also sort the resources to ensure the order of entities
|
||||
# created is deterministic since "APCMODEL" and "MODEL"
|
||||
# resources map to the same "Model" name.
|
||||
for resource in sorted(available_resources | {LAST_S_TEST}):
|
||||
if resource not in SENSORS:
|
||||
_LOGGER.warning("Invalid resource from APCUPSd: %s", resource.upper())
|
||||
@@ -527,9 +530,11 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update sensor attributes based on coordinator data."""
|
||||
key = self.entity_description.key.upper()
|
||||
# For most sensors the key will always be available for each refresh. However, some sensors
|
||||
# (e.g., "laststest") will only appear after certain event occurs (e.g., a self test is
|
||||
# performed) and may disappear again after certain event. So we mark the state as "unknown"
|
||||
# For most sensors the key will always be available for
|
||||
# each refresh. However, some sensors (e.g., "laststest")
|
||||
# will only appear after certain event occurs (e.g., a
|
||||
# self test is performed) and may disappear again after
|
||||
# certain event. So we mark the state as "unknown"
|
||||
# when it becomes unknown after such events.
|
||||
if key not in self.coordinator.data:
|
||||
self._attr_native_value = None
|
||||
@@ -538,7 +543,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
data = self.coordinator.data[key]
|
||||
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
# The date could be "N/A" for certain fields (e.g., XOFFBATT), indicating there is no value yet.
|
||||
# The date could be "N/A" for certain fields
|
||||
# (e.g., XOFFBATT), indicating there is no value yet.
|
||||
if data == "N/A":
|
||||
self._attr_native_value = None
|
||||
return
|
||||
@@ -546,7 +552,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
try:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
except dateutil.parser.ParserError, OverflowError:
|
||||
# If parsing fails we should mark it as unknown, with a log for further debugging.
|
||||
# If parsing fails we should mark it as unknown,
|
||||
# with a log for further debugging.
|
||||
_LOGGER.warning('Failed to parse date for %s: "%s"', key, data)
|
||||
self._attr_native_value = None
|
||||
return
|
||||
@@ -578,7 +585,8 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
entity_registry = er.async_get(self.hass)
|
||||
items = [
|
||||
f"- [{entry.name or entry.original_name or entity_id}]"
|
||||
f"(/config/{integration}/edit/{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
f"(/config/{integration}/edit/"
|
||||
f"{entry.unique_id or entity_id.split('.', 1)[-1]})"
|
||||
for integration, entities in (
|
||||
("automation", automations),
|
||||
("script", scripts),
|
||||
|
||||
@@ -406,7 +406,8 @@ class APIDomainServicesView(HomeAssistantView):
|
||||
is ha.SupportsResponse.NONE
|
||||
):
|
||||
return self.json_message(
|
||||
"Service does not support responses. Remove return_response from request.",
|
||||
"Service does not support responses."
|
||||
" Remove return_response from request.",
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
elif (
|
||||
|
||||
@@ -300,8 +300,10 @@ class AppleTVManager(DeviceListener):
|
||||
config_entry.title,
|
||||
address,
|
||||
)
|
||||
# We no longer multicast scan for the device since as soon as async_step_zeroconf runs,
|
||||
# it will update the address and reload the config entry when the device is found.
|
||||
# We no longer multicast scan for the device since as
|
||||
# soon as async_step_zeroconf runs, it will update the
|
||||
# address and reload the config entry when the device
|
||||
# is found.
|
||||
return None
|
||||
|
||||
async def _connect(self, conf: AppleTV, raise_missing_credentials: bool) -> None:
|
||||
|
||||
@@ -463,7 +463,8 @@ class AppleTvMediaPlayer(
|
||||
"""Implement the websocket media browsing helper."""
|
||||
if media_content_id == "apps" or (
|
||||
# If we can't stream files or URLs, we can't browse media.
|
||||
# In that case the `BROWSE_MEDIA` feature was added because of AppList/LaunchApp
|
||||
# In that case the `BROWSE_MEDIA` feature was added
|
||||
# because of AppList/LaunchApp
|
||||
not self._is_feature_available(FeatureName.PlayUrl)
|
||||
and not self._is_feature_available(FeatureName.StreamFile)
|
||||
):
|
||||
|
||||
@@ -18,10 +18,10 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import EVENT_TURN_ON
|
||||
from .const import DOMAIN, EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
@@ -52,7 +52,7 @@ def convert_exception[**_P, _R](
|
||||
return await func(*args, **kwargs)
|
||||
except ConnectionFailed as exception:
|
||||
raise HomeAssistantError(
|
||||
f"Connection failed to device during {func}"
|
||||
translation_domain=DOMAIN, translation_key="connection_failed"
|
||||
) from exception
|
||||
|
||||
return _convert_exception
|
||||
@@ -96,9 +96,12 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
"""Select a specific source."""
|
||||
try:
|
||||
value = SourceCodes[source]
|
||||
except KeyError:
|
||||
_LOGGER.error("Unsupported source %s", source)
|
||||
return
|
||||
except KeyError as exception:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_source",
|
||||
translation_placeholders={"source": source},
|
||||
) from exception
|
||||
|
||||
await self._state.set_source(value)
|
||||
self.async_write_ha_state()
|
||||
@@ -109,8 +112,10 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
try:
|
||||
await self._state.set_decode_mode(sound_mode)
|
||||
except (KeyError, ValueError) as exception:
|
||||
raise HomeAssistantError(
|
||||
f"Unsupported sound_mode {sound_mode}"
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_sound_mode",
|
||||
translation_placeholders={"sound_mode": sound_mode},
|
||||
) from exception
|
||||
|
||||
self.async_write_ha_state()
|
||||
@@ -193,8 +198,11 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
preset = int(media_id[7:])
|
||||
await self._state.set_tuner_preset(preset)
|
||||
else:
|
||||
_LOGGER.error("Media %s is not supported", media_id)
|
||||
return
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_media",
|
||||
translation_placeholders={"media": media_id},
|
||||
)
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
|
||||
@@ -139,5 +139,19 @@
|
||||
"name": "Incoming video vertical resolution"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_failed": {
|
||||
"message": "Connection failed to the device."
|
||||
},
|
||||
"unsupported_media": {
|
||||
"message": "Unsupported media: {media}."
|
||||
},
|
||||
"unsupported_sound_mode": {
|
||||
"message": "Unsupported sound mode: {sound_mode}."
|
||||
},
|
||||
"unsupported_source": {
|
||||
"message": "Unsupported source: {source}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,10 @@ class SpeechToTextError(PipelineError):
|
||||
|
||||
|
||||
class DuplicateWakeUpDetectedError(WakeWordDetectionError):
|
||||
"""Error when multiple voice assistants wake up at the same time (same wake word)."""
|
||||
"""Error when multiple voice assistants wake up at the same time.
|
||||
|
||||
Happens when multiple assistants detect the same wake word.
|
||||
"""
|
||||
|
||||
def __init__(self, wake_up_phrase: str) -> None:
|
||||
"""Set error message."""
|
||||
|
||||
@@ -589,7 +589,10 @@ class PipelineRun:
|
||||
"""Data tied to the conversation ID."""
|
||||
|
||||
_intent_agent_only = False
|
||||
"""If request should only be handled by agent, ignoring sentence triggers and local processing."""
|
||||
"""If request should only be handled by agent.
|
||||
|
||||
Ignores sentence triggers and local processing.
|
||||
"""
|
||||
|
||||
_streamed_response_text = False
|
||||
"""If the conversation agent streamed response text to TTS result."""
|
||||
@@ -932,6 +935,7 @@ class PipelineRun:
|
||||
{
|
||||
"engine": engine,
|
||||
"metadata": asdict(metadata),
|
||||
"audio_processing": asdict(self.stt_provider.audio_processing),
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -1045,7 +1049,11 @@ class PipelineRun:
|
||||
if agent_info is None:
|
||||
raise IntentRecognitionError(
|
||||
code="intent-agent-not-found",
|
||||
message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found",
|
||||
message=(
|
||||
f"Intent recognition engine"
|
||||
f" {self._conversation_data.continue_conversation_agent}"
|
||||
" asked for follow-up but is no longer found"
|
||||
),
|
||||
)
|
||||
self._intent_agent_only = True
|
||||
|
||||
@@ -1149,14 +1157,17 @@ class PipelineRun:
|
||||
|
||||
nonlocal delta_character_count
|
||||
|
||||
# Streamed responses are not cached. That's why we only start streaming text after
|
||||
# we have received enough characters that indicates it will be a long response
|
||||
# or if we have received text, and then a tool call.
|
||||
# Streamed responses are not cached. That's why we
|
||||
# only start streaming text after we have received
|
||||
# enough characters that indicates it will be a long
|
||||
# response or if we have received text, and then a
|
||||
# tool call.
|
||||
|
||||
# Tool call after we already received text
|
||||
start_streaming = delta_character_count > 0 and delta.get("tool_calls")
|
||||
|
||||
# Count characters in the content and test if we exceed streaming threshold
|
||||
# Count characters in the content and test if we
|
||||
# exceed streaming threshold
|
||||
if not start_streaming and content:
|
||||
delta_character_count += len(content)
|
||||
start_streaming = delta_character_count > STREAM_RESPONSE_CHARS
|
||||
@@ -1186,7 +1197,8 @@ class PipelineRun:
|
||||
parts.append(tts_input_stream.get_nowait())
|
||||
tts_input_stream.put_nowait(
|
||||
"".join(
|
||||
# At this point parts is only strings, None indicates end of queue
|
||||
# At this point parts is only strings,
|
||||
# None indicates end of queue
|
||||
cast(list[str], parts)
|
||||
)
|
||||
)
|
||||
@@ -1427,7 +1439,8 @@ class PipelineRun:
|
||||
code="tts-not-supported",
|
||||
message=(
|
||||
f"Text-to-speech engine {engine} "
|
||||
f"does not support language {self.pipeline.tts_language} or options {tts_options}:"
|
||||
f"does not support language {self.pipeline.tts_language}"
|
||||
f" or options {tts_options}:"
|
||||
f" {err}"
|
||||
),
|
||||
) from err
|
||||
@@ -1541,7 +1554,10 @@ class PipelineRun:
|
||||
async def process_volume_only(
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Apply volume transformation only (no VAD/audio enhancements) with optional chunking."""
|
||||
"""Apply volume transformation only with optional chunking.
|
||||
|
||||
No VAD/audio enhancements are applied.
|
||||
"""
|
||||
timestamp_ms = 0
|
||||
async for chunk in audio_stream:
|
||||
if self.audio_settings.volume_multiplier != 1.0:
|
||||
@@ -1560,7 +1576,11 @@ class PipelineRun:
|
||||
async def process_enhance_audio(
|
||||
self, audio_stream: AsyncIterable[bytes]
|
||||
) -> AsyncGenerator[EnhancedAudioChunk]:
|
||||
"""Split audio into chunks and apply VAD/noise suppression/auto gain/volume transformation."""
|
||||
"""Split audio into chunks and apply audio enhancements.
|
||||
|
||||
Applies VAD/noise suppression/auto gain/volume
|
||||
transformation.
|
||||
"""
|
||||
assert self.audio_enhancer is not None
|
||||
|
||||
timestamp_ms = 0
|
||||
@@ -1663,7 +1683,7 @@ class PipelineInput:
|
||||
"""Identifier of the device that is processing the input/output of the pipeline."""
|
||||
|
||||
satellite_id: str | None = None
|
||||
"""Identifier of the satellite that is processing the input/output of the pipeline."""
|
||||
"""Identifier of the satellite processing the pipeline."""
|
||||
|
||||
async def execute(self, validate: bool = False) -> None:
|
||||
"""Run pipeline."""
|
||||
@@ -1725,7 +1745,8 @@ class PipelineInput:
|
||||
sec_since_last_wake_up = time.monotonic() - last_wake_up
|
||||
if sec_since_last_wake_up < WAKE_WORD_COOLDOWN:
|
||||
_LOGGER.debug(
|
||||
"Speech-to-text cancelled to avoid duplicate wake-up for %s",
|
||||
"Speech-to-text cancelled to avoid"
|
||||
" duplicate wake-up for %s",
|
||||
self.wake_word_phrase,
|
||||
)
|
||||
raise DuplicateWakeUpDetectedError(self.wake_word_phrase)
|
||||
@@ -1738,7 +1759,8 @@ class PipelineInput:
|
||||
stt_input_stream = stt_processed_stream
|
||||
|
||||
if stt_audio_buffer:
|
||||
# Send audio in the buffer first to speech-to-text, then move on to stt_stream.
|
||||
# Send audio in the buffer first to speech-to-text,
|
||||
# then move on to stt_stream.
|
||||
# This is basically an async itertools.chain.
|
||||
async def buffer_then_audio_stream() -> AsyncGenerator[
|
||||
EnhancedAudioChunk
|
||||
@@ -2042,7 +2064,9 @@ class PipelineStorageCollectionWebsocket(
|
||||
msg["id"],
|
||||
{
|
||||
"pipelines": async_get_pipelines(hass),
|
||||
"preferred_pipeline": self.storage_collection.async_get_preferred_item(),
|
||||
"preferred_pipeline": (
|
||||
self.storage_collection.async_get_preferred_item()
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -204,7 +204,8 @@ class VoiceCommandSegmenter:
|
||||
) -> bool:
|
||||
"""Process an audio chunk using an external VAD.
|
||||
|
||||
A buffer is required if the VAD requires fixed-sized audio chunks (usually the case).
|
||||
A buffer is required if the VAD requires fixed-sized audio
|
||||
chunks (usually the case).
|
||||
|
||||
Returns False when voice command is finished.
|
||||
"""
|
||||
@@ -293,7 +294,10 @@ def chunk_samples(
|
||||
bytes_per_chunk: int,
|
||||
leftover_chunk_buffer: AudioBuffer,
|
||||
) -> Iterable[bytes]:
|
||||
"""Yield fixed-sized chunks from samples, keeping leftover bytes from previous call(s)."""
|
||||
"""Yield fixed-sized chunks from samples.
|
||||
|
||||
Keeps leftover bytes from previous call(s).
|
||||
"""
|
||||
|
||||
if (len(leftover_chunk_buffer) + len(samples)) < bytes_per_chunk:
|
||||
# Extend leftover chunk, but not enough samples to complete it
|
||||
|
||||
@@ -470,7 +470,7 @@ async def websocket_device_capture(
|
||||
# single sample (16 bits) per queue item.
|
||||
max_queue_items = (
|
||||
# +1 for None to signal end
|
||||
int(math.ceil(timeout_seconds * CAPTURE_RATE)) + 1
|
||||
math.ceil(timeout_seconds * CAPTURE_RATE) + 1
|
||||
)
|
||||
|
||||
audio_queue = DeviceAudioQueue(queue=asyncio.Queue(maxsize=max_queue_items))
|
||||
|
||||
@@ -291,7 +291,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._is_announcing = True
|
||||
self._set_state(AssistSatelliteState.RESPONDING)
|
||||
|
||||
# Provide our start info to the LLM so it understands context of incoming message
|
||||
# Provide our start info to the LLM so it understands
|
||||
# context of incoming message
|
||||
if extra_system_prompt is not None:
|
||||
self._extra_system_prompt = extra_system_prompt
|
||||
else:
|
||||
@@ -501,7 +502,8 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
with chat_session.async_get_chat_session(
|
||||
self.hass, self._conversation_id
|
||||
) as session:
|
||||
# Store the conversation ID. If it is no longer valid, get_chat_session will reset it
|
||||
# Store the conversation ID. If it is no longer valid,
|
||||
# get_chat_session will reset it
|
||||
self._conversation_id = session.conversation_id
|
||||
self._pipeline_task = (
|
||||
self.platform.config_entry.async_create_background_task(
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
@@ -35,6 +36,7 @@ from .bridge import AsusWrtBridge
|
||||
from .const import (
|
||||
CONF_DNSMASQ,
|
||||
CONF_INTERFACE,
|
||||
CONF_MORE_OPTIONS,
|
||||
CONF_REQUIRE_IP,
|
||||
CONF_SSH_KEY,
|
||||
CONF_TRACK_UNKNOWN,
|
||||
@@ -57,9 +59,6 @@ ALLOWED_PROTOCOL = [
|
||||
PROTOCOL_TELNET,
|
||||
]
|
||||
|
||||
PASS_KEY = "pass_key"
|
||||
PASS_KEY_MSG = "Only provide password or SSH key file"
|
||||
|
||||
RESULT_CONN_ERROR = "cannot_connect"
|
||||
RESULT_SUCCESS = "success"
|
||||
RESULT_UNKNOWN = "unknown"
|
||||
@@ -144,9 +143,7 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
schema = {
|
||||
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
|
||||
vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str,
|
||||
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
|
||||
vol.Optional(CONF_PASSWORD): str,
|
||||
vol.Required(
|
||||
CONF_PROTOCOL,
|
||||
default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS),
|
||||
@@ -155,6 +152,15 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
options=ALLOWED_PROTOCOL, translation_key="protocols"
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_MORE_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_SSH_KEY): str,
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -229,6 +235,10 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is None:
|
||||
return self._show_setup_form()
|
||||
|
||||
user_input = user_input.copy()
|
||||
more_options = user_input.pop(CONF_MORE_OPTIONS, {})
|
||||
user_input.update(more_options)
|
||||
|
||||
self._config_data = user_input
|
||||
pwd: str | None = user_input.get(CONF_PASSWORD)
|
||||
ssh: str | None = user_input.get(CONF_SSH_KEY)
|
||||
@@ -238,6 +248,8 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self._show_setup_form(error="pwd_required")
|
||||
if not (pwd or ssh):
|
||||
return self._show_setup_form(error="pwd_or_ssh")
|
||||
if pwd and ssh:
|
||||
return self._show_setup_form(error="pwd_and_ssh")
|
||||
if ssh and not await self.hass.async_add_executor_job(_is_file, ssh):
|
||||
return self._show_setup_form(error="ssh_not_file")
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ DOMAIN = "asuswrt"
|
||||
|
||||
CONF_DNSMASQ = "dnsmasq"
|
||||
CONF_INTERFACE = "interface"
|
||||
CONF_MORE_OPTIONS = "more_options"
|
||||
CONF_REQUIRE_IP = "require_ip"
|
||||
CONF_SSH_KEY = "ssh_key"
|
||||
CONF_TRACK_UNKNOWN = "track_unknown"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"pwd_and_ssh": "Please provide either password or SSH key file, not both",
|
||||
"pwd_or_ssh": "Please provide password or SSH key file",
|
||||
"pwd_required": "Password is required for selected protocol",
|
||||
"ssh_not_file": "SSH key file not found",
|
||||
@@ -23,15 +24,22 @@
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"port": "Port (leave empty for protocol default)",
|
||||
"protocol": "Communication protocol to use",
|
||||
"ssh_key": "Path to your SSH key file (instead of password)",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your ASUSWRT router."
|
||||
},
|
||||
"description": "Set required parameter to connect to your router"
|
||||
"description": "Set required parameter to connect to your router",
|
||||
"sections": {
|
||||
"more_options": {
|
||||
"data": {
|
||||
"port": "Port (leave empty for protocol default)",
|
||||
"ssh_key": "Path to your SSH key file (instead of password)"
|
||||
},
|
||||
"name": "More options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -75,7 +75,7 @@ class AtagThermostat(AtagEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., auto, manual, fireplace, extend, etc."""
|
||||
"""Return the current preset mode, e.g., auto, manual."""
|
||||
preset = self.coordinator.atag.climate.preset_mode
|
||||
return PRESET_INVERTED.get(preset)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity):
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the setpoint if water demand, otherwise return base temp (comfort level)."""
|
||||
"""Return the setpoint if water demand, otherwise base temp."""
|
||||
return self.coordinator.atag.dhw.target_temperature
|
||||
|
||||
@property
|
||||
|
||||
@@ -164,7 +164,7 @@ class AugustDoorbellBinarySensor(AugustDescriptionEntity, BinarySensorEntity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _schedule_update_to_recheck_turn_off_sensor(self) -> None:
|
||||
"""Schedule an update to recheck the sensor to see if it is ready to turn off."""
|
||||
"""Schedule an update to recheck if sensor is ready to turn off."""
|
||||
# If the sensor is already off there is nothing to do
|
||||
if not self.is_on:
|
||||
return
|
||||
|
||||
@@ -129,7 +129,10 @@ class AugustLock(AugustEntity, RestoreEntity, LockEntity):
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log."""
|
||||
"""Restore ATTR_CHANGED_BY on startup.
|
||||
|
||||
It is likely no longer in the activity log.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if not (last_state := await self.async_get_last_state()):
|
||||
|
||||
@@ -167,7 +167,10 @@ class AugustOperatorSensor(AugustEntity, RestoreSensor):
|
||||
return attributes
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log."""
|
||||
"""Restore ATTR_CHANGED_BY on startup.
|
||||
|
||||
It is likely no longer in the activity log.
|
||||
"""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
last_state = await self.async_get_last_state()
|
||||
|
||||
@@ -20,10 +20,16 @@ async def async_get_config_entry_diagnostics(
|
||||
"name": coordinator.account_site.system_name,
|
||||
"health": coordinator.account_site.health,
|
||||
"solar": {
|
||||
"power_production": coordinator.data.solar.power_production,
|
||||
"energy_production_today": coordinator.data.solar.energy_production_today,
|
||||
"energy_production_month": coordinator.data.solar.energy_production_month,
|
||||
"energy_production_total": coordinator.data.solar.energy_production_total,
|
||||
"power_production": (coordinator.data.solar.power_production),
|
||||
"energy_production_today": (
|
||||
coordinator.data.solar.energy_production_today
|
||||
),
|
||||
"energy_production_month": (
|
||||
coordinator.data.solar.energy_production_month
|
||||
),
|
||||
"energy_production_total": (
|
||||
coordinator.data.solar.energy_production_total
|
||||
),
|
||||
},
|
||||
"inverters": [
|
||||
{
|
||||
@@ -41,9 +47,15 @@ async def async_get_config_entry_diagnostics(
|
||||
"flow_now": coordinator.data.battery.flow_now,
|
||||
"net_charged_now": coordinator.data.battery.net_charged_now,
|
||||
"state_of_charge": coordinator.data.battery.state_of_charge,
|
||||
"discharged_today": coordinator.data.battery.discharged_today,
|
||||
"discharged_month": coordinator.data.battery.discharged_month,
|
||||
"discharged_total": coordinator.data.battery.discharged_total,
|
||||
"discharged_today": (
|
||||
coordinator.data.battery.discharged_today
|
||||
),
|
||||
"discharged_month": (
|
||||
coordinator.data.battery.discharged_month
|
||||
),
|
||||
"discharged_total": (
|
||||
coordinator.data.battery.discharged_total
|
||||
),
|
||||
"charged_today": coordinator.data.battery.charged_today,
|
||||
"charged_month": coordinator.data.battery.charged_month,
|
||||
"charged_total": coordinator.data.battery.charged_total,
|
||||
|
||||
@@ -52,7 +52,8 @@ flow for details.
|
||||
|
||||
Progress the flow. Most flows will be 1 page, but could optionally add extra
|
||||
login challenges, like TFA. Once the flow has finished, the returned step will
|
||||
have type FlowResultType.CREATE_ENTRY and "result" key will contain an authorization code.
|
||||
have type FlowResultType.CREATE_ENTRY and "result" key will contain
|
||||
an authorization code.
|
||||
The authorization code associated with an authorized user by default, it will
|
||||
associate with an credential if "type" set to "link_user" in
|
||||
"/auth/login_flow"
|
||||
@@ -226,7 +227,8 @@ class AuthProvidersView(HomeAssistantView):
|
||||
remote_address
|
||||
)
|
||||
except InvalidAuthError:
|
||||
# Not a trusted network, so we don't expose that trusted_network authenticator is setup
|
||||
# Not a trusted network, so we don't expose that
|
||||
# trusted_network authenticator is setup
|
||||
continue
|
||||
|
||||
providers.append(
|
||||
|
||||
@@ -1155,7 +1155,7 @@ async def _async_process_config(
|
||||
automations: list[BaseAutomationEntity],
|
||||
automation_configs: list[AutomationEntityConfig],
|
||||
) -> tuple[set[int], set[int]]:
|
||||
"""Find matches between a list of automation entities and a list of configurations.
|
||||
"""Find matches between automation entities and configurations.
|
||||
|
||||
An automation or configuration is only allowed to match at most once to handle
|
||||
the case of multiple automations with identical configuration.
|
||||
|
||||
@@ -164,17 +164,12 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
if self._discovery_info:
|
||||
disc = self._discovery_info
|
||||
label = f"{disc.name or disc.address} ({disc.address})"
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ADDRESS, default=self._discovery_info.address
|
||||
): vol.In(
|
||||
{
|
||||
self._discovery_info.address: (
|
||||
f"{self._discovery_info.name or self._discovery_info.address}"
|
||||
f" ({self._discovery_info.address})"
|
||||
)
|
||||
}
|
||||
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
|
||||
{disc.address: label}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -109,7 +109,8 @@ def _discover_bulbs_for_import() -> list[dict[str, str]]:
|
||||
|
||||
if brightness is None:
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
|
||||
"Skipping Avea bulb %s during YAML import due to"
|
||||
" read failure: brightness is None",
|
||||
address,
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -242,8 +242,9 @@ class S3BackupAgent(BackupAgent):
|
||||
finally:
|
||||
view.release()
|
||||
|
||||
# Compact the buffer if the consumed offset has grown large enough. This
|
||||
# avoids unnecessary memory copies when compacting after every part upload.
|
||||
# Compact the buffer if the consumed offset has grown
|
||||
# large enough. This avoids unnecessary memory copies
|
||||
# when compacting after every part upload.
|
||||
if offset and offset >= MULTIPART_MIN_PART_SIZE_BYTES:
|
||||
buffer = bytearray(buffer[offset:])
|
||||
offset = 0
|
||||
|
||||
@@ -72,9 +72,14 @@ class ADXConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
errors = await self.validate_input(user_input)
|
||||
if not errors:
|
||||
cluster = user_input[CONF_ADX_CLUSTER_INGEST_URI].replace(
|
||||
"https://", ""
|
||||
)
|
||||
db = user_input[CONF_ADX_DATABASE_NAME]
|
||||
table = user_input[CONF_ADX_TABLE_NAME]
|
||||
return self.async_create_entry(
|
||||
data=user_input,
|
||||
title=f"{user_input[CONF_ADX_CLUSTER_INGEST_URI].replace('https://', '')} / {user_input[CONF_ADX_DATABASE_NAME]} ({user_input[CONF_ADX_TABLE_NAME]})",
|
||||
title=f"{cluster} / {db} ({table})",
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,7 +134,8 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]):
|
||||
work_item_ids := await self.client.get_work_item_ids(
|
||||
self.organization,
|
||||
project_name,
|
||||
# Filter out completed and removed work items so we only get active work items
|
||||
# Filter out completed and removed work items
|
||||
# so we only get active work items
|
||||
states=work_item_types_states_filter(
|
||||
work_item_types,
|
||||
ignored_categories=IGNORED_CATEGORIES,
|
||||
|
||||
@@ -108,6 +108,7 @@ class ServiceBusNotificationService(BaseNotificationService):
|
||||
)
|
||||
try:
|
||||
await self._client.send_messages(queue_message)
|
||||
# pylint: disable-next=home-assistant-action-swallowed-exception
|
||||
except ServiceBusError as err:
|
||||
_LOGGER.error(
|
||||
"Could not send service bus notification to %s. %s",
|
||||
|
||||
@@ -41,7 +41,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) ->
|
||||
def _authorize_and_get_bucket_sync() -> Bucket:
|
||||
"""Synchronously authorize the Backblaze B2 account and retrieve the bucket.
|
||||
|
||||
This function runs in the event loop's executor as b2sdk operations are blocking.
|
||||
This function runs in the event loop's executor as
|
||||
b2sdk operations are blocking.
|
||||
"""
|
||||
b2_api.authorize_account(
|
||||
BACKBLAZE_REALM,
|
||||
|
||||
@@ -84,7 +84,10 @@ def _find_backup_file_for_metadata(
|
||||
def _create_backup_from_metadata(
|
||||
metadata_content: dict[str, Any], backup_file: FileVersion
|
||||
) -> AgentBackup:
|
||||
"""Construct an AgentBackup from parsed metadata content and the associated backup file."""
|
||||
"""Construct an AgentBackup from parsed metadata content.
|
||||
|
||||
Uses the associated backup file to set the size.
|
||||
"""
|
||||
metadata = metadata_content["backup_metadata"]
|
||||
metadata["size"] = backup_file.size
|
||||
return AgentBackup.from_dict(metadata)
|
||||
@@ -172,11 +175,13 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
"Attempting to delete partially uploaded backup file %s",
|
||||
filename,
|
||||
)
|
||||
|
||||
def _delete_uploaded_file() -> None:
|
||||
"""Look up and delete the partially uploaded backup file."""
|
||||
self._bucket.get_file_info_by_name(filename).delete()
|
||||
|
||||
try:
|
||||
uploaded_main_file_info = await self._hass.async_add_executor_job(
|
||||
self._bucket.get_file_info_by_name, filename
|
||||
)
|
||||
await self._hass.async_add_executor_job(uploaded_main_file_info.delete)
|
||||
await self._hass.async_add_executor_job(_delete_uploaded_file)
|
||||
except B2Error:
|
||||
_LOGGER.warning(
|
||||
"Failed to clean up partially uploaded backup file %s;"
|
||||
@@ -233,7 +238,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
) -> None:
|
||||
"""Upload a backup to Backblaze B2.
|
||||
|
||||
This involves uploading the main backup archive and a separate metadata JSON file.
|
||||
This involves uploading the main backup archive and a
|
||||
separate metadata JSON file.
|
||||
"""
|
||||
tar_filename, metadata_filename = suggested_filenames(backup)
|
||||
prefixed_tar_filename = self._prefix + tar_filename
|
||||
@@ -381,8 +387,12 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
metadata_file.file_name,
|
||||
)
|
||||
|
||||
await self._hass.async_add_executor_job(file.delete)
|
||||
await self._hass.async_add_executor_job(metadata_file.delete)
|
||||
def _delete_backup_files() -> None:
|
||||
"""Delete the backup file and its metadata file."""
|
||||
file.delete()
|
||||
metadata_file.delete()
|
||||
|
||||
await self._hass.async_add_executor_job(_delete_backup_files)
|
||||
|
||||
self._invalidate_caches(
|
||||
backup_id,
|
||||
@@ -393,7 +403,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
|
||||
@handle_b2_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List all backups by finding their associated metadata files in Backblaze B2."""
|
||||
"""List all backups by finding metadata files in B2."""
|
||||
async with self._backup_list_cache_lock:
|
||||
if self._backup_list_cache and self._is_cache_valid(
|
||||
self._backup_list_cache_expiration
|
||||
@@ -402,7 +412,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
return list(self._backup_list_cache.values())
|
||||
|
||||
_LOGGER.debug(
|
||||
"Cache expired or empty, fetching all files from B2 to build backup list"
|
||||
"Cache expired or empty, fetching all files"
|
||||
" from B2 to build backup list"
|
||||
)
|
||||
all_files_in_prefix = await self._get_all_files_in_prefix()
|
||||
|
||||
@@ -482,7 +493,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
async def _find_file_and_metadata_version_by_id(
|
||||
self, backup_id: str
|
||||
) -> tuple[FileVersion | None, FileVersion | None]:
|
||||
"""Find the main backup file and its associated metadata file version by backup ID."""
|
||||
"""Find the backup file and metadata file version by ID."""
|
||||
all_files_in_prefix = await self._get_all_files_in_prefix()
|
||||
|
||||
# Process metadata files sequentially to avoid exhausting executor pool
|
||||
@@ -504,7 +515,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"Timeout downloading metadata file %s while searching for backup %s",
|
||||
"Timeout downloading metadata file %s"
|
||||
" while searching for backup %s",
|
||||
file_name,
|
||||
backup_id,
|
||||
)
|
||||
@@ -556,7 +568,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
)
|
||||
if not found_backup_file:
|
||||
_LOGGER.warning(
|
||||
"Found metadata file %s for backup ID %s, but no corresponding backup file",
|
||||
"Found metadata file %s for backup ID %s,"
|
||||
" but no corresponding backup file",
|
||||
file_name,
|
||||
target_backup_id,
|
||||
)
|
||||
@@ -575,7 +588,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
|
||||
Uses a cache to minimize API calls.
|
||||
|
||||
This fetches a flat list of all files, including main backups and metadata files.
|
||||
This fetches a flat list of all files, including main
|
||||
backups and metadata files.
|
||||
"""
|
||||
async with self._all_files_cache_lock:
|
||||
if self._is_cache_valid(self._all_files_cache_expiration):
|
||||
@@ -603,7 +617,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
file_version: FileVersion,
|
||||
all_files_in_prefix: dict[str, FileVersion],
|
||||
) -> AgentBackup | None:
|
||||
"""Synchronously process a single metadata file and return an AgentBackup if valid."""
|
||||
"""Process a single metadata file and return an AgentBackup."""
|
||||
try:
|
||||
download_response = file_version.download().response
|
||||
except B2Error as err:
|
||||
@@ -648,7 +662,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
backup_id: The backup ID to remove from backup cache
|
||||
tar_filename: The tar filename to remove from files cache
|
||||
metadata_filename: The metadata filename to remove from files cache
|
||||
remove_files: If True, remove specific files from cache; if False, expire entire cache
|
||||
remove_files: If True, remove specific files from cache;
|
||||
if False, expire entire cache
|
||||
"""
|
||||
if remove_files:
|
||||
if self._is_cache_valid(self._all_files_cache_expiration):
|
||||
@@ -659,7 +674,8 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
if self._is_cache_valid(self._backup_list_cache_expiration):
|
||||
self._backup_list_cache.pop(backup_id, None)
|
||||
else:
|
||||
# For uploads, we can't easily add new FileVersion objects without API calls,
|
||||
# For uploads, we can't easily add new FileVersion
|
||||
# objects without API calls,
|
||||
# so we expire the entire cache for simplicity
|
||||
self._all_files_cache_expiration = 0.0
|
||||
self._backup_list_cache_expiration = 0.0
|
||||
|
||||
@@ -134,7 +134,8 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not REQUIRED_CAPABILITIES.issubset(current_caps):
|
||||
missing_caps = REQUIRED_CAPABILITIES - current_caps
|
||||
_LOGGER.warning(
|
||||
"Missing required Backblaze B2 capabilities for Key ID '%s': %s",
|
||||
"Missing required Backblaze B2 capabilities"
|
||||
" for Key ID '%s': %s",
|
||||
user_input[CONF_KEY_ID],
|
||||
", ".join(sorted(missing_caps)),
|
||||
)
|
||||
@@ -190,13 +191,15 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except exception.MissingAccountData:
|
||||
# This generally indicates an issue with how InMemoryAccountInfo is used
|
||||
_LOGGER.error(
|
||||
"Missing account data during Backblaze B2 authorization for Key ID '%s'",
|
||||
"Missing account data during Backblaze B2"
|
||||
" authorization for Key ID '%s'",
|
||||
user_input[CONF_KEY_ID],
|
||||
)
|
||||
errors["base"] = "invalid_credentials"
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"An unexpected error occurred during Backblaze B2 configuration for Key ID '%s'",
|
||||
"An unexpected error occurred during Backblaze B2"
|
||||
" configuration for Key ID '%s'",
|
||||
user_input[CONF_KEY_ID],
|
||||
)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
@@ -98,7 +98,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if not with_hassio:
|
||||
reader_writer = CoreBackupReaderWriter(hass)
|
||||
else:
|
||||
# pylint: disable-next=hass-component-root-import
|
||||
# pylint: disable-next=home-assistant-component-root-import
|
||||
from homeassistant.components.hassio.backup import ( # noqa: PLC0415
|
||||
SupervisorBackupReaderWriter,
|
||||
)
|
||||
|
||||
@@ -1187,10 +1187,10 @@ class BackupManager:
|
||||
"Cannot include all addons and specify specific addons"
|
||||
)
|
||||
|
||||
kind = "Automatic" if with_automatic_settings else "Custom"
|
||||
backup_name = (
|
||||
(name if name is None else name.strip())
|
||||
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
||||
)
|
||||
name if name is None else name.strip()
|
||||
) or f"{kind} backup {HAVERSION}"
|
||||
extra_metadata = extra_metadata or {}
|
||||
|
||||
try:
|
||||
@@ -1287,7 +1287,8 @@ class BackupManager:
|
||||
)
|
||||
if not agent_errors:
|
||||
if with_automatic_settings:
|
||||
# create backup was successful, update last_completed_automatic_backup
|
||||
# create backup was successful, update
|
||||
# last_completed_automatic_backup
|
||||
self.config.data.last_completed_automatic_backup = dt_util.now()
|
||||
self.store.save()
|
||||
backup_success = True
|
||||
@@ -2157,7 +2158,8 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
return
|
||||
|
||||
LOGGER.info(
|
||||
"Adjusting backup settings to not include addons, folders or supervisor locations"
|
||||
"Adjusting backup settings to not include addons,"
|
||||
" folders or supervisor locations"
|
||||
)
|
||||
automatic_agents = [
|
||||
agent_id
|
||||
|
||||
@@ -12,7 +12,22 @@
|
||||
"in_progress": "In progress"
|
||||
}
|
||||
},
|
||||
"failed_reason": { "name": "Failure reason" }
|
||||
"failed_reason": {
|
||||
"name": "Failure reason",
|
||||
"state": {
|
||||
"backup_agent_error": "Backup agent error",
|
||||
"backup_agent_unreachable": "Backup agent unreachable",
|
||||
"backup_manager_error": "Backup manager error",
|
||||
"backup_not_found": "Backup not found",
|
||||
"backup_reader_writer_error": "Backup reader/writer error",
|
||||
"decrypt_on_download_not_supported": "Decrypt on download not supported",
|
||||
"invalid_backup_filename": "Invalid backup filename",
|
||||
"multiple_errors": "Multiple errors",
|
||||
"password_incorrect": "Password incorrect",
|
||||
"unknown_error": "Unknown error",
|
||||
"upload_failed": "Upload failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user