mirror of
https://github.com/home-assistant/core.git
synced 2025-10-26 03:59:32 +00:00
Compare commits
285 Commits
drop-ignor
...
chat-log-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1383d30e7 | ||
|
|
4c9810a10e | ||
|
|
83e9fca6a2 | ||
|
|
fc9313f7ef | ||
|
|
278f32285a | ||
|
|
8c360908ef | ||
|
|
82c5337fcf | ||
|
|
7950f9ab38 | ||
|
|
66eeb41e56 | ||
|
|
1bef707cd1 | ||
|
|
2125a4123d | ||
|
|
27516dee6a | ||
|
|
40c9e5356e | ||
|
|
2521920376 | ||
|
|
16eb8315ee | ||
|
|
2c3d65b461 | ||
|
|
7e938f4f13 | ||
|
|
c5c4cf0284 | ||
|
|
68c38ac047 | ||
|
|
f5a6fa8be1 | ||
|
|
3c751918fd | ||
|
|
a3c8760b3f | ||
|
|
7bceaf74be | ||
|
|
750f06327a | ||
|
|
98bffdb9d3 | ||
|
|
174b0f7c01 | ||
|
|
807edc9f47 | ||
|
|
9b0f67fbde | ||
|
|
27390647ff | ||
|
|
d9f6a6bf99 | ||
|
|
67be4f863f | ||
|
|
447fb68085 | ||
|
|
750a7c9797 | ||
|
|
3b7e1a2610 | ||
|
|
c11cacbb58 | ||
|
|
d8d6490fb4 | ||
|
|
2341f53ac0 | ||
|
|
94e8ffadd2 | ||
|
|
5186c402e7 | ||
|
|
4403447d53 | ||
|
|
9a8a2bfebc | ||
|
|
a907a34f6e | ||
|
|
03b15f1dba | ||
|
|
a48b915d90 | ||
|
|
e8227baa50 | ||
|
|
4d525dee48 | ||
|
|
c38e02072e | ||
|
|
2d84bd65fe | ||
|
|
1d8eaeb8af | ||
|
|
33ed851477 | ||
|
|
a571271f6a | ||
|
|
43445f5945 | ||
|
|
3c098db35e | ||
|
|
c72072fbfb | ||
|
|
10e2c7ec95 | ||
|
|
64493ca578 | ||
|
|
5f008dcae5 | ||
|
|
80c06c689c | ||
|
|
b19070fa4b | ||
|
|
53984f7abc | ||
|
|
38620a0cda | ||
|
|
b09927bb53 | ||
|
|
11b9f7915b | ||
|
|
3ddb520693 | ||
|
|
ffc54c699d | ||
|
|
91b516d739 | ||
|
|
25a9948020 | ||
|
|
f0f420ebe2 | ||
|
|
312812dd8b | ||
|
|
e0d404456b | ||
|
|
439fc18860 | ||
|
|
774ab06206 | ||
|
|
f484db8f0e | ||
|
|
4af3c4f720 | ||
|
|
a020a32d8a | ||
|
|
1ac2ae3443 | ||
|
|
2fce7db132 | ||
|
|
6e49911e1c | ||
|
|
4215a16285 | ||
|
|
65ff4fe10e | ||
|
|
5b7675e389 | ||
|
|
3019744035 | ||
|
|
21ab630380 | ||
|
|
564ff12db0 | ||
|
|
6c919e698f | ||
|
|
5d644815fa | ||
|
|
8dfa0f2f65 | ||
|
|
f9484acbfa | ||
|
|
d0c0247086 | ||
|
|
b116619af1 | ||
|
|
a3d760156f | ||
|
|
6e194ad6ef | ||
|
|
1e2a21b69f | ||
|
|
e90fe96b4e | ||
|
|
4774ed508a | ||
|
|
8f4a4d4c47 | ||
|
|
a83bbe2332 | ||
|
|
e5b93d3275 | ||
|
|
1c024f58af | ||
|
|
fa86148df0 | ||
|
|
7c6bbb97ea | ||
|
|
a5af501da4 | ||
|
|
f23cfb5594 | ||
|
|
67a12dc007 | ||
|
|
5783b3a576 | ||
|
|
7bc43039bd | ||
|
|
23e2316c36 | ||
|
|
9e9c8f5724 | ||
|
|
11772dbc46 | ||
|
|
c12df5d776 | ||
|
|
b57ca143e6 | ||
|
|
b3e16bd4fa | ||
|
|
18d5035877 | ||
|
|
d6db50fcc7 | ||
|
|
84d9fa3bd7 | ||
|
|
b08eb3a201 | ||
|
|
c74c317922 | ||
|
|
9edc6249ca | ||
|
|
4fbcb79889 | ||
|
|
68fd5bc67e | ||
|
|
882d047bb5 | ||
|
|
5c070c8f03 | ||
|
|
854882d612 | ||
|
|
b078c0ee7e | ||
|
|
080b16a33d | ||
|
|
6a1cf9827c | ||
|
|
23e7b14eae | ||
|
|
2a5cf83f50 | ||
|
|
5dcb68cdf6 | ||
|
|
fedeca107a | ||
|
|
4fef19c7bc | ||
|
|
8c953b0c4e | ||
|
|
949544874f | ||
|
|
237407010a | ||
|
|
64e48816c7 | ||
|
|
6b76b3e729 | ||
|
|
4912280193 | ||
|
|
d4e72ad2cf | ||
|
|
711526fc6c | ||
|
|
4be428fce7 | ||
|
|
ea226806a0 | ||
|
|
bc77daf2ce | ||
|
|
acead56bd5 | ||
|
|
fd08c55b79 | ||
|
|
0c342c4750 | ||
|
|
da6986e58c | ||
|
|
2f5fbc1f0e | ||
|
|
e79c76cd35 | ||
|
|
6edafd8965 | ||
|
|
204ff5d45f | ||
|
|
591eb94515 | ||
|
|
0f3de627c5 | ||
|
|
b2699d8a03 | ||
|
|
769a770cf1 | ||
|
|
2d96e8ac4d | ||
|
|
354cacdcae | ||
|
|
d999dd05d1 | ||
|
|
81572c6a84 | ||
|
|
8165ac196f | ||
|
|
41c95247ec | ||
|
|
2eb3360e8c | ||
|
|
fcd07902b0 | ||
|
|
71f94cad97 | ||
|
|
05277aa708 | ||
|
|
9f74471d22 | ||
|
|
1c8487a7e7 | ||
|
|
3c8612b6fd | ||
|
|
f28892c526 | ||
|
|
24b7cf261c | ||
|
|
ef69e6d54b | ||
|
|
ca31a279fa | ||
|
|
e50c4c4787 | ||
|
|
3ecddda8dd | ||
|
|
af77f835a5 | ||
|
|
6de2016aa3 | ||
|
|
f1e72c1616 | ||
|
|
7af3eb638b | ||
|
|
363e5f088c | ||
|
|
5b1e3ef574 | ||
|
|
d607323731 | ||
|
|
31f595a3f8 | ||
|
|
9a27805349 | ||
|
|
477cdbb711 | ||
|
|
62b39fdd10 | ||
|
|
f806cc8b4b | ||
|
|
b6108001e4 | ||
|
|
56f33a8a5f | ||
|
|
1e91ad6e23 | ||
|
|
9032de4b26 | ||
|
|
553fcb5156 | ||
|
|
378295e1cc | ||
|
|
ff95c6235f | ||
|
|
d398a13899 | ||
|
|
10b300e573 | ||
|
|
e95c0ef3a8 | ||
|
|
3b09adb360 | ||
|
|
d2380608e1 | ||
|
|
37188a0832 | ||
|
|
3134fd75e8 | ||
|
|
861f4a0578 | ||
|
|
a82c512472 | ||
|
|
10392d9719 | ||
|
|
b7acc66153 | ||
|
|
6249cabcba | ||
|
|
84f2fd106d | ||
|
|
45cc68d3e4 | ||
|
|
7fd75c7742 | ||
|
|
9522b11042 | ||
|
|
c874c4ac73 | ||
|
|
907ef8fa15 | ||
|
|
bc93153c40 | ||
|
|
6964829699 | ||
|
|
62e59608b0 | ||
|
|
9507b3f3aa | ||
|
|
1d187abe10 | ||
|
|
0464cb8929 | ||
|
|
f410d94f80 | ||
|
|
dee3c11203 | ||
|
|
06e4b0a798 | ||
|
|
2fd55a49cb | ||
|
|
80d7224dcf | ||
|
|
9d03b1b9b4 | ||
|
|
cecdf553f3 | ||
|
|
54e6fbc042 | ||
|
|
9c098d3471 | ||
|
|
394575e4f7 | ||
|
|
effc33d0d2 | ||
|
|
7af4c337c6 | ||
|
|
4f222d7adf | ||
|
|
00f16812e4 | ||
|
|
0efaf7efe8 | ||
|
|
55643f0632 | ||
|
|
36f4723f6e | ||
|
|
03bc698936 | ||
|
|
0c1dc73422 | ||
|
|
c31537081b | ||
|
|
d13067abb3 | ||
|
|
64da32b5f9 | ||
|
|
3990fc6ab2 | ||
|
|
e4071bd305 | ||
|
|
8dda26c227 | ||
|
|
b182d5ce87 | ||
|
|
175365bdea | ||
|
|
cbe52cbfca | ||
|
|
9251dde2c6 | ||
|
|
24d77cc453 | ||
|
|
a1f98abe49 | ||
|
|
d25dde1d11 | ||
|
|
8ec483b38b | ||
|
|
bf14caca69 | ||
|
|
e5fb6b2fb2 | ||
|
|
7dfeb3a3f6 | ||
|
|
9d3b1562c4 | ||
|
|
e14407f066 | ||
|
|
67872e3746 | ||
|
|
06bd1a2003 | ||
|
|
37ea360304 | ||
|
|
25ce57424c | ||
|
|
3d46ab549d | ||
|
|
567cc9f842 | ||
|
|
b5457a5abd | ||
|
|
e4b5e35d1d | ||
|
|
12023c33b5 | ||
|
|
a28749937c | ||
|
|
3fe37d651f | ||
|
|
cb3424cdf0 | ||
|
|
a799f7ff91 | ||
|
|
34ab725b75 | ||
|
|
2dfc7f02ba | ||
|
|
c8919222bd | ||
|
|
a888264d2f | ||
|
|
ae84c7e15d | ||
|
|
415c8b490b | ||
|
|
6038f15406 | ||
|
|
a8758253c4 | ||
|
|
fa4eb2e820 | ||
|
|
58f35d0614 | ||
|
|
f72a91ca29 | ||
|
|
5d99da6e1f | ||
|
|
64746eb99c | ||
|
|
70fc6df599 | ||
|
|
8dc33ece7b | ||
|
|
3d4d8e7f20 | ||
|
|
c92d319e12 | ||
|
|
1bdba7906a |
@@ -33,7 +33,7 @@
|
||||
"GitHub.vscode-pull-request-github",
|
||||
"GitHub.copilot"
|
||||
],
|
||||
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json
|
||||
// Please keep this file in sync with settings in home-assistant/.vscode/settings.default.jsonc
|
||||
"settings": {
|
||||
"python.experiments.optOutFrom": ["pythonTestAdapter"],
|
||||
"python.defaultInterpreterPath": "/home/vscode/.local/ha-venv/bin/python",
|
||||
@@ -41,6 +41,7 @@
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
@@ -62,6 +63,9 @@
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
|
||||
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -74,6 +74,7 @@ rules:
|
||||
- **Formatting**: Ruff
|
||||
- **Linting**: PyLint and Ruff
|
||||
- **Type Checking**: MyPy
|
||||
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
|
||||
- **Testing**: pytest with plain functions and fixtures
|
||||
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
|
||||
|
||||
|
||||
8
.github/workflows/builder.yml
vendored
8
.github/workflows/builder.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
@@ -326,7 +326,7 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
|
||||
- name: Download translations
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
|
||||
|
||||
30
.github/workflows/ci.yaml
vendored
30
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 9
|
||||
CACHE_VERSION: 1
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.11"
|
||||
@@ -428,7 +428,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ${{ fromJSON(needs.info.outputs.python_versions) }}
|
||||
python-version: &matrix-python ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
steps:
|
||||
- *checkout
|
||||
- &setup-python-matrix
|
||||
@@ -514,9 +514,7 @@ jobs:
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
${{ env.APT_CACHE_DIR }}
|
||||
${{ env.APT_LIST_CACHE_DIR }}
|
||||
path: *path-apt-cache
|
||||
key: *key-apt-cache
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
@@ -537,7 +535,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -641,7 +639,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
python-version: *matrix-python
|
||||
steps:
|
||||
- *checkout
|
||||
- *setup-python-matrix
|
||||
@@ -689,14 +687,14 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint homeassistant
|
||||
pylint --ignore-missing-annotations=y homeassistant
|
||||
- name: Run pylint (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pylint homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
pylint --ignore-missing-annotations=y homeassistant/components/${{ needs.info.outputs.integrations_glob }}
|
||||
|
||||
pylint-tests:
|
||||
name: Check pylint on tests
|
||||
@@ -838,8 +836,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
python-version: *matrix-python
|
||||
group: &matrix-group ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
- name: Install additional OS dependencies
|
||||
@@ -869,7 +867,7 @@ jobs:
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
|
||||
- name: Download pytest_buckets
|
||||
uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: pytest_buckets
|
||||
- &compile-english-translations
|
||||
@@ -964,7 +962,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
python-version: *matrix-python
|
||||
mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
@@ -1081,7 +1079,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
python-version: *matrix-python
|
||||
postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }}
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
@@ -1218,8 +1216,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJson(needs.info.outputs.python_versions) }}
|
||||
group: ${{ fromJson(needs.info.outputs.test_groups) }}
|
||||
python-version: *matrix-python
|
||||
group: *matrix-group
|
||||
steps:
|
||||
- *cache-restore-apt
|
||||
- name: Install additional OS dependencies
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
|
||||
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
78
.github/workflows/wheels.yml
vendored
78
.github/workflows/wheels.yml
vendored
@@ -31,7 +31,8 @@ jobs:
|
||||
outputs:
|
||||
architectures: ${{ steps.info.outputs.architectures }}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
- &checkout
|
||||
name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
@@ -91,7 +92,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -99,14 +100,14 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: *actions-upload-artifact
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: *actions-upload-artifact
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -118,7 +119,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: *actions-upload-artifact
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
@@ -127,28 +128,41 @@ jobs:
|
||||
name: Build Core wheels ${{ matrix.abi }} for ${{ matrix.arch }} (musllinux_1_2)
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp313"]
|
||||
matrix: &matrix-build
|
||||
abi: ["cp313", "cp314"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
- arch: aarch64
|
||||
os: ubuntu-24.04-arm
|
||||
exclude:
|
||||
- abi: cp314
|
||||
arch: armv7
|
||||
- abi: cp314
|
||||
arch: armhf
|
||||
- abi: cp314
|
||||
arch: i386
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- *checkout
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- &download-env-file
|
||||
name: Download env_file
|
||||
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- &download-build-constraints
|
||||
name: Download build_constraints
|
||||
uses: *actions-download-artifact
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
- &download-requirements-diff
|
||||
name: Download requirements_diff
|
||||
uses: *actions-download-artifact
|
||||
with:
|
||||
name: requirements_diff
|
||||
|
||||
@@ -160,7 +174,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
uses: &home-assistant-wheels home-assistant/wheels@2025.10.0
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
@@ -177,33 +191,19 @@ jobs:
|
||||
name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }}
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
needs: init
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp313"]
|
||||
arch: ${{ fromJson(needs.init.outputs.architectures) }}
|
||||
matrix: *matrix-build
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- *checkout
|
||||
|
||||
- name: Download env_file
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: env_file
|
||||
|
||||
- name: Download build_constraints
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: build_constraints
|
||||
|
||||
- name: Download requirements_diff
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: requirements_diff
|
||||
- *download-env-file
|
||||
- *download-build-constraints
|
||||
- *download-requirements-diff
|
||||
|
||||
- name: Download requirements_all_wheels
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: *actions-download-artifact
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support sha pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@2025.09.1
|
||||
uses: *home-assistant-wheels
|
||||
with:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -111,6 +111,7 @@ virtualization/vagrant/config
|
||||
!.vscode/cSpell.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/settings.default.jsonc
|
||||
.env
|
||||
|
||||
# Windows Explorer
|
||||
@@ -140,4 +141,5 @@ pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude/settings.local.json
|
||||
.serena/
|
||||
|
||||
|
||||
@@ -182,7 +182,6 @@ homeassistant.components.efergy.*
|
||||
homeassistant.components.eheimdigital.*
|
||||
homeassistant.components.electrasmart.*
|
||||
homeassistant.components.electric_kiwi.*
|
||||
homeassistant.components.elevenlabs.*
|
||||
homeassistant.components.elgato.*
|
||||
homeassistant.components.elkm1.*
|
||||
homeassistant.components.emulated_hue.*
|
||||
@@ -279,6 +278,7 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
@@ -478,6 +478,7 @@ homeassistant.components.skybell.*
|
||||
homeassistant.components.slack.*
|
||||
homeassistant.components.sleep_as_android.*
|
||||
homeassistant.components.sleepiq.*
|
||||
homeassistant.components.sma.*
|
||||
homeassistant.components.smhi.*
|
||||
homeassistant.components.smlight.*
|
||||
homeassistant.components.smtp.*
|
||||
|
||||
@@ -7,13 +7,19 @@
|
||||
"python.testing.pytestEnabled": false,
|
||||
// https://code.visualstudio.com/docs/python/linting#_general-settings
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
// Pyright is too pedantic for Home Assistant
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"[json][jsonc][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": [
|
||||
"homeassistant/components/*/manifest.json"
|
||||
],
|
||||
// This value differs between working with devcontainer and locally, therefor this value should NOT be in sync!
|
||||
"url": "./script/json_schemas/manifest_schema.json"
|
||||
}
|
||||
]
|
||||
{
|
||||
"fileMatch": ["homeassistant/components/*/manifest.json"],
|
||||
// This value differs between working with devcontainer and locally, therefore this value should NOT be in sync!
|
||||
"url": "./script/json_schemas/manifest_schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
CODEOWNERS
generated
8
CODEOWNERS
generated
@@ -494,6 +494,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/filesize/ @gjohansson-ST
|
||||
/homeassistant/components/filter/ @dgomes
|
||||
/tests/components/filter/ @dgomes
|
||||
/homeassistant/components/fing/ @Lorenzo-Gasparini
|
||||
/tests/components/fing/ @Lorenzo-Gasparini
|
||||
/homeassistant/components/firefly_iii/ @erwindouna
|
||||
/tests/components/firefly_iii/ @erwindouna
|
||||
/homeassistant/components/fireservicerota/ @cyberjunky
|
||||
@@ -619,6 +621,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/greeneye_monitor/ @jkeljo
|
||||
/homeassistant/components/group/ @home-assistant/core
|
||||
/tests/components/group/ @home-assistant/core
|
||||
/homeassistant/components/growatt_server/ @johanzander
|
||||
/tests/components/growatt_server/ @johanzander
|
||||
/homeassistant/components/guardian/ @bachya
|
||||
/tests/components/guardian/ @bachya
|
||||
/homeassistant/components/habitica/ @tr4nt0r
|
||||
@@ -739,6 +743,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
/tests/components/incomfort/ @jbouwh
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
@@ -1537,6 +1543,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/sunricher_dali_center/ @niracler
|
||||
/tests/components/sunricher_dali_center/ @niracler
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
|
||||
4
Dockerfile
generated
4
Dockerfile
generated
@@ -25,13 +25,13 @@ RUN \
|
||||
"armv7") go2rtc_suffix='arm' ;; \
|
||||
*) go2rtc_suffix=${BUILD_ARCH} ;; \
|
||||
esac \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.11/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
|
||||
&& chmod +x /bin/go2rtc \
|
||||
# Verify go2rtc can be executed
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.8.9
|
||||
RUN pip3 install uv==0.9.5
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
13
build.yaml
13
build.yaml
@@ -1,13 +1,10 @@
|
||||
image: ghcr.io/home-assistant/{arch}-homeassistant
|
||||
build_from:
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.0
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.0
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.0
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.0
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.0
|
||||
codenotary:
|
||||
signer: notary@home-assistant.io
|
||||
base_image: notary@home-assistant.io
|
||||
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.10.1
|
||||
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.10.1
|
||||
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.10.1
|
||||
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.10.1
|
||||
i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.10.1
|
||||
cosign:
|
||||
base_identity: https://github.com/home-assistant/docker/.*
|
||||
identity: https://github.com/home-assistant/core/.*
|
||||
|
||||
@@ -34,6 +34,9 @@ INPUT_FIELD_CODE = "code"
|
||||
|
||||
DUMMY_SECRET = "FPPTH34D4E3MI2HG"
|
||||
|
||||
GOOGLE_AUTHENTICATOR_URL = "https://support.google.com/accounts/answer/1066447"
|
||||
AUTHY_URL = "https://authy.com/"
|
||||
|
||||
|
||||
def _generate_qr_code(data: str) -> str:
|
||||
"""Generate a base64 PNG string represent QR Code image of data."""
|
||||
@@ -229,6 +232,8 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
|
||||
"code": self._ota_secret,
|
||||
"url": self._url,
|
||||
"qr_code": self._image,
|
||||
"google_authenticator_url": GOOGLE_AUTHENTICATOR_URL,
|
||||
"authy_url": AUTHY_URL,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/adax",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adax", "adax_local"],
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.1.5"]
|
||||
"requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -53,9 +53,6 @@ __all__ = [
|
||||
"GenImageTaskResult",
|
||||
"async_generate_data",
|
||||
"async_generate_image",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,6 +26,10 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Documentation URL for API key generation
|
||||
_API_KEY_URL = "https://docs.airnowapi.org/account/request/"
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
@@ -114,6 +118,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
}
|
||||
),
|
||||
description_placeholders={"api_key_url": _API_KEY_URL},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "To generate API key go to https://docs.airnowapi.org/account/request/",
|
||||
"description": "To generate API key go to {api_key_url}",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["airos==0.5.6"]
|
||||
"requirements": ["airos==0.6.0"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)",
|
||||
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0"
|
||||
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behavior is to clip such values to 0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==1.0.1"]
|
||||
"requirements": ["aioairzone==1.0.2"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.4.4"]
|
||||
"requirements": ["aioamazondevices==6.4.6"]
|
||||
}
|
||||
|
||||
@@ -41,6 +41,11 @@ APPS_NEW_ID = "add_new"
|
||||
CONF_APP_DELETE = "app_delete"
|
||||
CONF_APP_ID = "app_id"
|
||||
|
||||
_EXAMPLE_APP_ID = "com.plexapp.android"
|
||||
_EXAMPLE_APP_PLAY_STORE_URL = (
|
||||
f"https://play.google.com/store/apps/details?id={_EXAMPLE_APP_ID}"
|
||||
)
|
||||
|
||||
STEP_PAIR_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("pin"): str,
|
||||
@@ -355,5 +360,7 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload):
|
||||
data_schema=data_schema,
|
||||
description_placeholders={
|
||||
"app_id": f"`{app_id}`" if app_id != APPS_NEW_ID else "",
|
||||
"example_app_id": _EXAMPLE_APP_ID,
|
||||
"example_app_play_store_url": _EXAMPLE_APP_PLAY_STORE_URL,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"app_name": "Name of the application as you would like it to be displayed in Home Assistant.",
|
||||
"app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android",
|
||||
"app_id": "E.g. {example_app_id} for {example_app_play_store_url}",
|
||||
"app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename",
|
||||
"app_delete": "Check this box to delete the application from the list."
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ from .pipeline import (
|
||||
async_setup_pipeline_store,
|
||||
async_update_pipeline,
|
||||
)
|
||||
from .select import AssistPipelineSelect, VadSensitivitySelect
|
||||
from .vad import VadSensitivity
|
||||
from .websocket_api import async_register_websocket_api
|
||||
|
||||
__all__ = (
|
||||
@@ -51,16 +53,18 @@ __all__ = (
|
||||
"SAMPLE_CHANNELS",
|
||||
"SAMPLE_RATE",
|
||||
"SAMPLE_WIDTH",
|
||||
"AssistPipelineSelect",
|
||||
"AudioSettings",
|
||||
"Pipeline",
|
||||
"PipelineEvent",
|
||||
"PipelineEventType",
|
||||
"PipelineNotFound",
|
||||
"VadSensitivity",
|
||||
"VadSensitivitySelect",
|
||||
"WakeWordSettings",
|
||||
"async_create_default_pipeline",
|
||||
"async_get_pipelines",
|
||||
"async_pipeline_from_audio_stream",
|
||||
"async_setup",
|
||||
"async_update_pipeline",
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,14 @@ import wave
|
||||
import hass_nabucasa
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation, stt, tts, wake_word, websocket_api
|
||||
from homeassistant.components import (
|
||||
conversation,
|
||||
media_player,
|
||||
stt,
|
||||
tts,
|
||||
wake_word,
|
||||
websocket_api,
|
||||
)
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -130,7 +137,10 @@ SAVE_DELAY = 10
|
||||
@callback
|
||||
def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
|
||||
"""Filter out intents that are not local fallback."""
|
||||
return result.intent.name in (intent.INTENT_GET_STATE)
|
||||
return result.intent.name in (
|
||||
intent.INTENT_GET_STATE,
|
||||
media_player.INTENT_MEDIA_SEARCH_AND_PLAY,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -72,7 +72,16 @@ class WrtDevice(NamedTuple):
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type _FuncType[_T] = Callable[[_T], Awaitable[list[Any] | tuple[Any] | dict[str, Any]]]
|
||||
type _FuncType[_T] = Callable[
|
||||
[_T],
|
||||
Awaitable[
|
||||
list[str]
|
||||
| tuple[float | None, float | None]
|
||||
| list[float]
|
||||
| dict[str, float | str | None]
|
||||
| dict[str, float]
|
||||
],
|
||||
]
|
||||
type _ReturnFuncType[_T] = Callable[[_T], Coroutine[Any, Any, dict[str, Any]]]
|
||||
|
||||
|
||||
@@ -87,7 +96,9 @@ def handle_errors_and_zip[_AsusWrtBridgeT: AsusWrtBridge](
|
||||
"""Run library methods and zip results or manage exceptions."""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def _wrapper(self: _AsusWrtBridgeT) -> dict[str, str]:
|
||||
async def _wrapper(
|
||||
self: _AsusWrtBridgeT,
|
||||
) -> dict[str, float | str | None] | dict[str, float]:
|
||||
try:
|
||||
data = await func(self)
|
||||
except exceptions as exc:
|
||||
@@ -114,7 +125,9 @@ class AsusWrtBridge(ABC):
|
||||
|
||||
@staticmethod
|
||||
def get_bridge(
|
||||
hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None
|
||||
hass: HomeAssistant,
|
||||
conf: dict[str, str | int],
|
||||
options: dict[str, str | bool | int] | None = None,
|
||||
) -> AsusWrtBridge:
|
||||
"""Get Bridge instance."""
|
||||
if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP):
|
||||
@@ -313,22 +326,22 @@ class AsusWrtLegacyBridge(AsusWrtBridge):
|
||||
return [SENSORS_TEMPERATURES_LEGACY[i] for i in range(3) if availability[i]]
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES)
|
||||
async def _get_bytes(self) -> Any:
|
||||
async def _get_bytes(self) -> tuple[float | None, float | None]:
|
||||
"""Fetch byte information from the router."""
|
||||
return await self._api.async_get_bytes_total()
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_RATES)
|
||||
async def _get_rates(self) -> Any:
|
||||
async def _get_rates(self) -> tuple[float, float]:
|
||||
"""Fetch rates information from the router."""
|
||||
return await self._api.async_get_current_transfer_rates()
|
||||
|
||||
@handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_LOAD_AVG)
|
||||
async def _get_load_avg(self) -> Any:
|
||||
async def _get_load_avg(self) -> list[float]:
|
||||
"""Fetch load average information from the router."""
|
||||
return await self._api.async_get_loadavg()
|
||||
|
||||
@handle_errors_and_zip((OSError, ValueError), None)
|
||||
async def _get_temperatures(self) -> Any:
|
||||
async def _get_temperatures(self) -> dict[str, float]:
|
||||
"""Fetch temperatures information from the router."""
|
||||
return await self._api.async_get_temperature()
|
||||
|
||||
|
||||
@@ -175,12 +175,12 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
async def _async_check_connection(
|
||||
self, user_input: dict[str, Any]
|
||||
self, user_input: dict[str, str | int]
|
||||
) -> tuple[str, str | None]:
|
||||
"""Attempt to connect the AsusWrt router."""
|
||||
|
||||
api: AsusWrtBridge
|
||||
host: str = user_input[CONF_HOST]
|
||||
host = user_input[CONF_HOST]
|
||||
protocol = user_input[CONF_PROTOCOL]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class AsusWrtRouter:
|
||||
|
||||
self._on_close: list[Callable] = []
|
||||
|
||||
self._options: dict[str, Any] = {
|
||||
self._options: dict[str, str | bool | int] = {
|
||||
CONF_DNSMASQ: DEFAULT_DNSMASQ,
|
||||
CONF_INTERFACE: DEFAULT_INTERFACE,
|
||||
CONF_REQUIRE_IP: True,
|
||||
@@ -299,12 +299,10 @@ class AsusWrtRouter:
|
||||
_LOGGER.warning("Reconnected to ASUS router %s", self.host)
|
||||
|
||||
self._connected_devices = len(wrt_devices)
|
||||
consider_home: int = self._options.get(
|
||||
CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds()
|
||||
)
|
||||
track_unknown: bool = self._options.get(
|
||||
CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN
|
||||
consider_home = int(
|
||||
self._options.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds())
|
||||
)
|
||||
track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN)
|
||||
|
||||
for device_mac, device in self._devices.items():
|
||||
dev_info = wrt_devices.pop(device_mac, None)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Set up two-factor authentication using TOTP",
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -109,12 +109,12 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
self._get_reauth_entry(), data_updates=config
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
return self.async_update_and_abort(
|
||||
self._get_reconfigure_entry(), data_updates=config
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
@@ -248,7 +248,7 @@ class AxisFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(discovery_info[CONF_MAC])
|
||||
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}
|
||||
updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"state": {
|
||||
"title": "Add a Bayesian sensor",
|
||||
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
|
||||
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behavior can be overridden by adding observations for the same entity's other states.",
|
||||
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
|
||||
@@ -113,7 +113,6 @@ __all__ = [
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"HomeAssistantRemoteScanner",
|
||||
"async_address_present",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bring_api"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["bring-api==1.1.0"]
|
||||
"requirements": ["bring-api==1.1.1"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.14.2"]
|
||||
"requirements": ["bthome-ble==3.15.0"]
|
||||
}
|
||||
|
||||
@@ -74,7 +74,10 @@ from .const import (
|
||||
StreamType,
|
||||
)
|
||||
from .helper import get_camera_from_entity_id
|
||||
from .img_util import scale_jpeg_camera_image
|
||||
from .img_util import (
|
||||
TurboJPEGSingleton, # noqa: F401
|
||||
scale_jpeg_camera_image,
|
||||
)
|
||||
from .prefs import (
|
||||
CameraPreferences,
|
||||
DynamicStreamSettings, # noqa: F401
|
||||
|
||||
@@ -816,13 +816,20 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return MediaPlayerState.PAUSED
|
||||
if media_status.player_is_idle:
|
||||
return MediaPlayerState.IDLE
|
||||
if self.app_id is not None and self.app_id != pychromecast.IDLE_APP_ID:
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
if self._chromecast is not None and self._chromecast.is_idle:
|
||||
# If library consider us idle, that is our off state
|
||||
# it takes HDMI status into account for cast devices.
|
||||
return MediaPlayerState.OFF
|
||||
|
||||
if self.app_id in APP_IDS_UNRELIABLE_MEDIA_INFO:
|
||||
# Some apps don't report media status, show the player as playing
|
||||
return MediaPlayerState.PLAYING
|
||||
|
||||
if self.app_id is not None:
|
||||
# We have an active app
|
||||
return MediaPlayerState.IDLE
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from .const import DEFAULT_PORT, DOMAIN
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -49,6 +50,8 @@ class CertexpiryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._errors[CONF_HOST] = "connection_timeout"
|
||||
except ConnectionRefused:
|
||||
self._errors[CONF_HOST] = "connection_refused"
|
||||
except ConnectionReset:
|
||||
self._errors[CONF_HOST] = "connection_reset"
|
||||
except ValidationFailure:
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -25,3 +25,7 @@ class ConnectionTimeout(TemporaryFailure):
|
||||
|
||||
class ConnectionRefused(TemporaryFailure):
|
||||
"""Network connection refused."""
|
||||
|
||||
|
||||
class ConnectionReset(TemporaryFailure):
|
||||
"""Network connection reset."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.util.ssl import get_default_context
|
||||
from .const import TIMEOUT
|
||||
from .errors import (
|
||||
ConnectionRefused,
|
||||
ConnectionReset,
|
||||
ConnectionTimeout,
|
||||
ResolveFailed,
|
||||
ValidationFailure,
|
||||
@@ -58,6 +59,8 @@ async def get_cert_expiry_timestamp(
|
||||
raise ConnectionRefused(
|
||||
f"Connection refused by server: {hostname}:{port}"
|
||||
) from err
|
||||
except ConnectionResetError as err:
|
||||
raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err
|
||||
except ssl.CertificateError as err:
|
||||
raise ValidationFailure(err.verify_message) from err
|
||||
except ssl.SSLError as err:
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"error": {
|
||||
"resolve_failed": "This host cannot be resolved",
|
||||
"connection_timeout": "Timeout when connecting to this host",
|
||||
"connection_refused": "Connection refused when connecting to host"
|
||||
"connection_refused": "Connection refused when connecting to host",
|
||||
"connection_reset": "Connection reset when connecting to host"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
smart_home as alexa_smart_home,
|
||||
)
|
||||
from homeassistant.components.camera.webrtc import async_register_ice_servers
|
||||
from homeassistant.components.camera import async_register_ice_servers
|
||||
from homeassistant.components.google_assistant import smart_home as ga
|
||||
from homeassistant.const import __version__ as HA_VERSION
|
||||
from homeassistant.core import Context, HassJob, HomeAssistant, callback
|
||||
|
||||
@@ -12,7 +12,9 @@ from hass_nabucasa.google_report_state import ErrorResponse
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
|
||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||
from homeassistant.components.google_assistant.helpers import ( # pylint: disable=hass-component-root-import
|
||||
AbstractConfig,
|
||||
)
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.3.0"],
|
||||
"requirements": ["hass-nabucasa==1.4.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from hass_nabucasa.voice import MAP_VOICE, Gender
|
||||
from homeassistant.auth.const import GROUP_ID_ADMIN
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.components.google_assistant.http import (
|
||||
from homeassistant.components.google_assistant.http import ( # pylint: disable=hass-component-root-import
|
||||
async_get_users as async_get_google_assistant_users,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -78,7 +78,10 @@ class CompitConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"compit_url": "https://inext.compit.pl/"},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Please enter your https://inext.compit.pl/ credentials.",
|
||||
"description": "Please enter your {compit_url} credentials.",
|
||||
"title": "Connect to Compit iNext",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
|
||||
@@ -6,7 +6,9 @@ from typing import Any
|
||||
import uuid
|
||||
|
||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||
from homeassistant.components.automation.config import async_validate_config_item
|
||||
from homeassistant.components.automation.config import ( # pylint: disable=hass-component-root-import
|
||||
async_validate_config_item,
|
||||
)
|
||||
from homeassistant.config import AUTOMATION_CONFIG_PATH
|
||||
from homeassistant.const import CONF_ID, SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
|
||||
from homeassistant.components.script.config import async_validate_config_item
|
||||
from homeassistant.components.script.config import ( # pylint: disable=hass-component-root-import
|
||||
async_validate_config_item,
|
||||
)
|
||||
from homeassistant.config import SCRIPT_CONFIG_PATH
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -34,7 +34,7 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
301
homeassistant/components/control4/climate.py
Normal file
301
homeassistant/components/control4/climate.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Platform for Control4 Climate/Thermostat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyControl4.climate import C4Climate
|
||||
from pyControl4.error_handling import C4Exception
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
|
||||
from .const import CONTROL4_ENTITY_TYPE
|
||||
from .director_utils import update_variables_for_config_entry
|
||||
from .entity import Control4Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONTROL4_CATEGORY = "comfort"
|
||||
|
||||
# Control4 variable names
|
||||
CONTROL4_HVAC_STATE = "HVAC_STATE"
|
||||
CONTROL4_HVAC_MODE = "HVAC_MODE"
|
||||
CONTROL4_CURRENT_TEMPERATURE = "TEMPERATURE_F"
|
||||
CONTROL4_HUMIDITY = "HUMIDITY"
|
||||
CONTROL4_COOL_SETPOINT = "COOL_SETPOINT_F"
|
||||
CONTROL4_HEAT_SETPOINT = "HEAT_SETPOINT_F"
|
||||
|
||||
VARIABLES_OF_INTEREST = {
|
||||
CONTROL4_HVAC_STATE,
|
||||
CONTROL4_HVAC_MODE,
|
||||
CONTROL4_CURRENT_TEMPERATURE,
|
||||
CONTROL4_HUMIDITY,
|
||||
CONTROL4_COOL_SETPOINT,
|
||||
CONTROL4_HEAT_SETPOINT,
|
||||
}
|
||||
|
||||
# Map Control4 HVAC modes to Home Assistant
|
||||
C4_TO_HA_HVAC_MODE = {
|
||||
"Off": HVACMode.OFF,
|
||||
"Cool": HVACMode.COOL,
|
||||
"Heat": HVACMode.HEAT,
|
||||
"Auto": HVACMode.HEAT_COOL,
|
||||
}
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map Control4 HVAC state to Home Assistant HVAC action
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"heating": HVACAction.HEATING,
|
||||
"cooling": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"off": HVACAction.OFF,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: Control4ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Control4 thermostats from a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
async def async_update_data() -> dict[int, dict[str, Any]]:
|
||||
"""Fetch data from Control4 director for thermostats."""
|
||||
try:
|
||||
return await update_variables_for_config_entry(
|
||||
hass, entry, VARIABLES_OF_INTEREST
|
||||
)
|
||||
except C4Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="climate",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_refresh()
|
||||
|
||||
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
|
||||
entity_list = []
|
||||
for item in items_of_category:
|
||||
try:
|
||||
if item["type"] == CONTROL4_ENTITY_TYPE:
|
||||
item_name = item["name"]
|
||||
item_id = item["id"]
|
||||
item_parent_id = item["parentId"]
|
||||
item_manufacturer = None
|
||||
item_device_name = None
|
||||
item_model = None
|
||||
|
||||
for parent_item in items_of_category:
|
||||
if parent_item["id"] == item_parent_id:
|
||||
item_manufacturer = parent_item.get("manufacturer")
|
||||
item_device_name = parent_item.get("roomName")
|
||||
item_model = parent_item.get("model")
|
||||
else:
|
||||
continue
|
||||
except KeyError:
|
||||
_LOGGER.exception(
|
||||
"Unknown device properties received from Control4: %s",
|
||||
item,
|
||||
)
|
||||
continue
|
||||
|
||||
# Skip if we don't have data for this thermostat
|
||||
if item_id not in coordinator.data:
|
||||
_LOGGER.warning(
|
||||
"Couldn't get climate state data for %s (ID: %s), skipping setup",
|
||||
item_name,
|
||||
item_id,
|
||||
)
|
||||
continue
|
||||
|
||||
entity_list.append(
|
||||
Control4Climate(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
item_name,
|
||||
item_id,
|
||||
item_device_name,
|
||||
item_manufacturer,
|
||||
item_model,
|
||||
item_parent_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entity_list)
|
||||
|
||||
|
||||
class Control4Climate(Control4Entity, ClimateEntity):
|
||||
"""Control4 climate entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.HEAT_COOL]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime_data: Control4RuntimeData,
|
||||
coordinator: DataUpdateCoordinator[dict[int, dict[str, Any]]],
|
||||
name: str,
|
||||
idx: int,
|
||||
device_name: str | None,
|
||||
device_manufacturer: str | None,
|
||||
device_model: str | None,
|
||||
device_id: int,
|
||||
) -> None:
|
||||
"""Initialize Control4 climate entity."""
|
||||
super().__init__(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
name,
|
||||
idx,
|
||||
device_name,
|
||||
device_manufacturer,
|
||||
device_model,
|
||||
device_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._thermostat_data is not None
|
||||
|
||||
def _create_api_object(self) -> C4Climate:
|
||||
"""Create a pyControl4 device object.
|
||||
|
||||
This exists so the director token used is always the latest one, without needing to re-init the entire entity.
|
||||
"""
|
||||
return C4Climate(self.runtime_data.director, self._idx)
|
||||
|
||||
@property
|
||||
def _thermostat_data(self) -> dict[str, Any] | None:
|
||||
"""Return the thermostat data from the coordinator."""
|
||||
return self.coordinator.data.get(self._idx)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
return data.get(CONTROL4_CURRENT_TEMPERATURE)
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
humidity = data.get(CONTROL4_HUMIDITY)
|
||||
return int(humidity) if humidity is not None else None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return current HVAC mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return HVACMode.OFF
|
||||
c4_mode = data.get(CONTROL4_HVAC_MODE) or ""
|
||||
return C4_TO_HA_HVAC_MODE.get(c4_mode, HVACMode.OFF)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return current HVAC action."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
c4_state = data.get(CONTROL4_HVAC_STATE)
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
hvac_mode = self.hvac_mode
|
||||
if hvac_mode == HVACMode.COOL:
|
||||
return data.get(CONTROL4_COOL_SETPOINT)
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
return data.get(CONTROL4_HEAT_SETPOINT)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the high target temperature for auto mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return data.get(CONTROL4_COOL_SETPOINT)
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the low target temperature for auto mode."""
|
||||
data = self._thermostat_data
|
||||
if data is None:
|
||||
return None
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return data.get(CONTROL4_HEAT_SETPOINT)
|
||||
return None
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target HVAC mode."""
|
||||
c4_hvac_mode = HA_TO_C4_HVAC_MODE[hvac_mode]
|
||||
c4_climate = self._create_api_object()
|
||||
await c4_climate.setHvacMode(c4_hvac_mode)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
c4_climate = self._create_api_object()
|
||||
low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
# Handle temperature range for auto mode
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
if low_temp is not None:
|
||||
await c4_climate.setHeatSetpointF(low_temp)
|
||||
if high_temp is not None:
|
||||
await c4_climate.setCoolSetpointF(high_temp)
|
||||
# Handle single temperature setpoint
|
||||
elif temp is not None:
|
||||
if self.hvac_mode == HVACMode.COOL:
|
||||
await c4_climate.setCoolSetpointF(temp)
|
||||
elif self.hvac_mode == HVACMode.HEAT:
|
||||
await c4_climate.setHeatSetpointF(temp)
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -87,7 +87,6 @@ __all__ = [
|
||||
"async_get_chat_log",
|
||||
"async_get_result_from_chat_log",
|
||||
"async_set_agent",
|
||||
"async_setup",
|
||||
"async_unset_agent",
|
||||
]
|
||||
|
||||
|
||||
@@ -20,10 +20,13 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import ChatLogEventType
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs")
|
||||
|
||||
SUBSCRIPTIONS: HassKey[list[Callable[[ChatLogEventType, dict[str, Any]], None]]] = (
|
||||
HassKey("conversation_chat_log_subscriptions")
|
||||
)
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
@@ -31,6 +34,37 @@ current_chat_log: ContextVar[ChatLog | None] = ContextVar(
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
callback_func: Callable[[ChatLogEventType, dict[str, Any]], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to all chat logs."""
|
||||
subscriptions = hass.data.get(SUBSCRIPTIONS)
|
||||
if subscriptions is None:
|
||||
subscriptions = []
|
||||
hass.data[SUBSCRIPTIONS] = subscriptions
|
||||
|
||||
subscriptions.append(callback_func)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from chat logs."""
|
||||
subscriptions.remove(callback_func)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_subscribers(
|
||||
hass: HomeAssistant, event_type: ChatLogEventType, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Notify subscribers of a chat log event."""
|
||||
if subscriptions := hass.data.get(SUBSCRIPTIONS):
|
||||
for callback_func in subscriptions:
|
||||
callback_func(event_type, data)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
@@ -63,6 +97,8 @@ def async_get_chat_log(
|
||||
all_chat_logs = {}
|
||||
hass.data[DATA_CHAT_LOGS] = all_chat_logs
|
||||
|
||||
is_new_log = session.conversation_id not in all_chat_logs
|
||||
|
||||
if chat_log := all_chat_logs.get(session.conversation_id):
|
||||
chat_log = replace(chat_log, content=chat_log.content.copy())
|
||||
else:
|
||||
@@ -71,6 +107,12 @@ def async_get_chat_log(
|
||||
if chat_log_delta_listener:
|
||||
chat_log.delta_listener = chat_log_delta_listener
|
||||
|
||||
# Fire CREATED event for new chat logs before any content is added
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.CREATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
chat_log.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
@@ -84,14 +126,26 @@ def async_get_chat_log(
|
||||
LOGGER.debug(
|
||||
"Chat Log opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
# If this was a new log but nothing was added, fire DELETED to clean up
|
||||
if is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
return
|
||||
|
||||
if session.conversation_id not in all_chat_logs:
|
||||
if is_new_log:
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_chat_logs.pop(session.conversation_id)
|
||||
_async_notify_subscribers(
|
||||
hass,
|
||||
ChatLogEventType.DELETED,
|
||||
{"conversation_id": session.conversation_id},
|
||||
)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
@@ -100,6 +154,13 @@ def async_get_chat_log(
|
||||
|
||||
all_chat_logs[session.conversation_id] = chat_log
|
||||
|
||||
# For new logs, CREATED was already fired before content was added
|
||||
# For existing logs, fire UPDATED
|
||||
if not is_new_log:
|
||||
_async_notify_subscribers(
|
||||
hass, ChatLogEventType.UPDATED, {"chat_log": chat_log.as_dict()}
|
||||
)
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
@@ -130,6 +191,10 @@ class SystemContent:
|
||||
role: Literal["system"] = field(init=False, default="system")
|
||||
content: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {"role": self.role, "content": self.content}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContent:
|
||||
@@ -139,6 +204,15 @@ class UserContent:
|
||||
content: str
|
||||
attachments: list[Attachment] | None = field(default=None)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "content": self.content}
|
||||
if self.attachments:
|
||||
result["attachments"] = [
|
||||
attachment.as_dict() for attachment in self.attachments
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Attachment:
|
||||
@@ -153,6 +227,14 @@ class Attachment:
|
||||
path: Path
|
||||
"""Path to the attachment on disk."""
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the attachment."""
|
||||
return {
|
||||
"media_content_id": self.media_content_id,
|
||||
"mime_type": self.mime_type,
|
||||
"path": str(self.path),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
@@ -165,6 +247,17 @@ class AssistantContent:
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
native: Any = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
result: dict[str, Any] = {"role": self.role, "agent_id": self.agent_id}
|
||||
if self.content:
|
||||
result["content"] = self.content
|
||||
if self.thinking_content:
|
||||
result["thinking_content"] = self.thinking_content
|
||||
if self.tool_calls:
|
||||
result["tool_calls"] = self.tool_calls
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResultContent:
|
||||
@@ -176,6 +269,16 @@ class ToolResultContent:
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the content."""
|
||||
return {
|
||||
"role": self.role,
|
||||
"agent_id": self.agent_id,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"tool_name": self.tool_name,
|
||||
"tool_result": self.tool_result,
|
||||
}
|
||||
|
||||
|
||||
type Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
|
||||
@@ -211,6 +314,13 @@ class ChatLog:
|
||||
delta_listener: Callable[[ChatLog, dict], None] | None = None
|
||||
llm_input_provided_index = 0
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dictionary representation of the chat log."""
|
||||
return {
|
||||
"conversation_id": self.conversation_id,
|
||||
"continue_conversation": self.continue_conversation,
|
||||
}
|
||||
|
||||
@property
|
||||
def continue_conversation(self) -> bool:
|
||||
"""Return whether the conversation should continue."""
|
||||
@@ -241,6 +351,11 @@ class ChatLog:
|
||||
"""Add user content to the log."""
|
||||
LOGGER.debug("Adding user content: %s", content)
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
@@ -259,6 +374,11 @@ class ChatLog:
|
||||
):
|
||||
raise ValueError("Non-external tool calls not allowed")
|
||||
self.content.append(content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{"conversation_id": self.conversation_id, "content": content.as_dict()},
|
||||
)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self,
|
||||
@@ -317,6 +437,14 @@ class ChatLog:
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.CONTENT_ADDED,
|
||||
{
|
||||
"conversation_id": self.conversation_id,
|
||||
"content": response_content.as_dict(),
|
||||
},
|
||||
)
|
||||
yield response_content
|
||||
|
||||
async def async_add_delta_content_stream(
|
||||
@@ -569,14 +697,17 @@ class ChatLog:
|
||||
if llm_api:
|
||||
prompt_parts.append(llm_api.api_prompt)
|
||||
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.BASE_PROMPT,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
# Append current date and time to the prompt if the corresponding tool is not provided
|
||||
llm_tools: list[llm.Tool] = llm_api.tools if llm_api else []
|
||||
if not any(tool.name.endswith("GetDateTime") for tool in llm_tools):
|
||||
prompt_parts.append(
|
||||
await self._async_expand_prompt_template(
|
||||
llm_context,
|
||||
llm.DATE_TIME_PROMPT,
|
||||
llm_context.language,
|
||||
user_name,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if extra_system_prompt := (
|
||||
# Take new system prompt if one was given
|
||||
@@ -590,6 +721,11 @@ class ChatLog:
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
_async_notify_subscribers(
|
||||
self.hass,
|
||||
ChatLogEventType.UPDATED,
|
||||
{"conversation_id": self.conversation_id, "chat_log": self.as_dict()},
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import IntFlag
|
||||
from enum import IntFlag, StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
@@ -30,3 +30,12 @@ class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
CREATED = "created"
|
||||
UPDATED = "updated"
|
||||
DELETED = "deleted"
|
||||
CONTENT_ADDED = "content_added"
|
||||
|
||||
@@ -20,6 +20,7 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .chat_log import async_subscribe_chat_logs
|
||||
from .const import DATA_COMPONENT
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput
|
||||
@@ -35,6 +36,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_sentences)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
|
||||
websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
|
||||
websocket_api.async_register_command(hass, websocket_subscribe_chat_logs)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
@@ -265,3 +267,28 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
)
|
||||
|
||||
return self.json(result.as_dict())
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "conversation/chat_log/subscribe",
|
||||
}
|
||||
)
|
||||
@websocket_api.require_admin
|
||||
def websocket_subscribe_chat_logs(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to all chat logs."""
|
||||
|
||||
@callback
|
||||
def forward_events(event_type: str, data: dict) -> None:
|
||||
"""Forward chat log events to websocket connection."""
|
||||
connection.send_message(
|
||||
{"type": "event", "event_type": event_type, "data": data}
|
||||
)
|
||||
|
||||
unsubscribe = async_subscribe_chat_logs(hass, forward_events)
|
||||
connection.subscriptions[msg["id"]] = unsubscribe
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.1"]
|
||||
"requirements": ["pycync==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
except TimeoutError:
|
||||
@@ -231,7 +232,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
updates={
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
self.context.update(
|
||||
@@ -265,7 +267,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_HOST: self.host,
|
||||
CONF_PORT: self.port,
|
||||
CONF_API_KEY: self.api_key,
|
||||
}
|
||||
},
|
||||
reload_on_update=False,
|
||||
)
|
||||
|
||||
self.context["configuration_url"] = HASSIO_CONFIGURATION_URL
|
||||
|
||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
|
||||
from homeassistant.components.manual.alarm_control_panel import ManualAlarm
|
||||
from homeassistant.components.manual.alarm_control_panel import ( # pylint: disable=hass-component-root-import
|
||||
ManualAlarm,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -139,6 +139,7 @@ class DemoCover(CoverEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._is_opening = False
|
||||
self._is_closing = True
|
||||
self._listen_cover()
|
||||
self._requested_closing = True
|
||||
@@ -162,6 +163,7 @@ class DemoCover(CoverEntity):
|
||||
return
|
||||
|
||||
self._is_opening = True
|
||||
self._is_closing = False
|
||||
self._listen_cover()
|
||||
self._requested_closing = False
|
||||
self.async_write_ha_state()
|
||||
@@ -181,10 +183,14 @@ class DemoCover(CoverEntity):
|
||||
if self._position == position:
|
||||
return
|
||||
|
||||
self._is_closing = position < (self._position or 0)
|
||||
self._is_opening = not self._is_closing
|
||||
|
||||
self._listen_cover()
|
||||
self._requested_closing = (
|
||||
self._position is not None and position < self._position
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover til to a specific position."""
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_utc_time_change
|
||||
|
||||
OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
|
||||
|
||||
@@ -23,6 +25,8 @@ async def async_setup_entry(
|
||||
[
|
||||
DemoValve("Front Garden", ValveState.OPEN),
|
||||
DemoValve("Orchard", ValveState.CLOSED),
|
||||
DemoValve("Back Garden", ValveState.CLOSED, position=70),
|
||||
DemoValve("Trees", ValveState.CLOSED, position=30),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -37,6 +41,7 @@ class DemoValve(ValveEntity):
|
||||
name: str,
|
||||
state: str,
|
||||
moveable: bool = True,
|
||||
position: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the valve."""
|
||||
self._attr_name = name
|
||||
@@ -46,11 +51,23 @@ class DemoValve(ValveEntity):
|
||||
)
|
||||
self._state = state
|
||||
self._moveable = moveable
|
||||
self._attr_reports_position = False
|
||||
self._unsub_listener_valve: CALLBACK_TYPE | None = None
|
||||
self._set_position: int = 0
|
||||
self._position: int = 0
|
||||
if position is None:
|
||||
return
|
||||
|
||||
self._position = self._set_position = position
|
||||
self._attr_reports_position = True
|
||||
self._attr_supported_features |= (
|
||||
ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP
|
||||
)
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Return true if valve is open."""
|
||||
return self._state == ValveState.OPEN
|
||||
def current_valve_position(self) -> int:
|
||||
"""Return current position of valve."""
|
||||
return self._position
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool:
|
||||
@@ -67,11 +84,6 @@ class DemoValve(ValveEntity):
|
||||
"""Return true if valve is closed."""
|
||||
return self._state == ValveState.CLOSED
|
||||
|
||||
@property
|
||||
def reports_position(self) -> bool:
|
||||
"""Return True if entity reports position, False otherwise."""
|
||||
return False
|
||||
|
||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||
"""Open the valve."""
|
||||
self._state = ValveState.OPENING
|
||||
@@ -87,3 +99,45 @@ class DemoValve(ValveEntity):
|
||||
await asyncio.sleep(OPEN_CLOSE_DELAY)
|
||||
self._state = ValveState.CLOSED
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_stop_valve(self) -> None:
|
||||
"""Stop the valve."""
|
||||
self._state = ValveState.OPEN if self._position > 0 else ValveState.CLOSED
|
||||
if self._unsub_listener_valve is not None:
|
||||
self._unsub_listener_valve()
|
||||
self._unsub_listener_valve = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_valve_position(self, position: int) -> None:
|
||||
"""Move the valve to a specific position."""
|
||||
if position == self._position:
|
||||
return
|
||||
if position > self._position:
|
||||
self._state = ValveState.OPENING
|
||||
else:
|
||||
self._state = ValveState.CLOSING
|
||||
|
||||
self._set_position = round(position, -1)
|
||||
self._listen_valve()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _listen_valve(self) -> None:
|
||||
"""Listen for changes in valve."""
|
||||
if self._unsub_listener_valve is None:
|
||||
self._unsub_listener_valve = async_track_utc_time_change(
|
||||
self.hass, self._time_changed_valve
|
||||
)
|
||||
|
||||
async def _time_changed_valve(self, now: datetime) -> None:
|
||||
"""Track time changes."""
|
||||
if self._state == ValveState.OPENING:
|
||||
self._position += 10
|
||||
elif self._state == ValveState.CLOSING:
|
||||
self._position -= 10
|
||||
|
||||
if self._position in (100, 0, self._set_position):
|
||||
await self.async_stop_valve()
|
||||
return
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
from .config_entry import (
|
||||
from .config_entry import ( # noqa: F401
|
||||
ScannerEntity,
|
||||
ScannerEntityDescription,
|
||||
TrackerEntity,
|
||||
@@ -15,7 +15,7 @@ from .config_entry import (
|
||||
async_setup_entry,
|
||||
async_unload_entry,
|
||||
)
|
||||
from .const import (
|
||||
from .const import ( # noqa: F401
|
||||
ATTR_ATTRIBUTES,
|
||||
ATTR_BATTERY,
|
||||
ATTR_DEV_ID,
|
||||
@@ -37,7 +37,7 @@ from .const import (
|
||||
SCAN_INTERVAL,
|
||||
SourceType,
|
||||
)
|
||||
from .legacy import (
|
||||
from .legacy import ( # noqa: F401
|
||||
PLATFORM_SCHEMA,
|
||||
PLATFORM_SCHEMA_BASE,
|
||||
SERVICE_SEE,
|
||||
@@ -61,44 +61,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the device tracker."""
|
||||
async_setup_legacy_integration(hass, config)
|
||||
return True
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ATTR_ATTRIBUTES",
|
||||
"ATTR_BATTERY",
|
||||
"ATTR_DEV_ID",
|
||||
"ATTR_GPS",
|
||||
"ATTR_HOST_NAME",
|
||||
"ATTR_IP",
|
||||
"ATTR_LOCATION_NAME",
|
||||
"ATTR_MAC",
|
||||
"ATTR_SOURCE_TYPE",
|
||||
"CONF_CONSIDER_HOME",
|
||||
"CONF_NEW_DEVICE_DEFAULTS",
|
||||
"CONF_SCAN_INTERVAL",
|
||||
"CONF_TRACK_NEW",
|
||||
"CONNECTED_DEVICE_REGISTERED",
|
||||
"DEFAULT_CONSIDER_HOME",
|
||||
"DEFAULT_TRACK_NEW",
|
||||
"DOMAIN",
|
||||
"ENTITY_ID_FORMAT",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"SCAN_INTERVAL",
|
||||
"SERVICE_SEE",
|
||||
"SERVICE_SEE_PAYLOAD_SCHEMA",
|
||||
"SOURCE_TYPES",
|
||||
"AsyncSeeCallback",
|
||||
"DeviceScanner",
|
||||
"ScannerEntity",
|
||||
"ScannerEntityDescription",
|
||||
"SeeCallback",
|
||||
"SourceType",
|
||||
"TrackerEntity",
|
||||
"TrackerEntityDescription",
|
||||
"async_setup",
|
||||
"async_setup_entry",
|
||||
"async_unload_entry",
|
||||
"is_on",
|
||||
"see",
|
||||
)
|
||||
|
||||
@@ -80,8 +80,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138
|
||||
class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
|
||||
class DevoloScannerEntity(
|
||||
CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]],
|
||||
ScannerEntity,
|
||||
):
|
||||
|
||||
@@ -122,10 +122,12 @@ class WanIpSensor(SensorEntity):
|
||||
try:
|
||||
async with asyncio.timeout(10):
|
||||
response = await self.resolver.query(self.hostname, self.querytype)
|
||||
except TimeoutError:
|
||||
except TimeoutError as err:
|
||||
_LOGGER.debug("Timeout while resolving host: %s", err)
|
||||
await self.resolver.close()
|
||||
except DNSError as err:
|
||||
_LOGGER.warning("Exception while resolving host: %s", err)
|
||||
await self.resolver.close()
|
||||
|
||||
if response:
|
||||
sorted_ips = sort_ips(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pydoods"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
|
||||
"requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"requirements": ["pydroplet==2.3.4"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@mib1185", "@edenhaus", "@Augar"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==15.1.0"]
|
||||
|
||||
@@ -8,8 +8,11 @@ from eheimdigital.classic_vario import EheimDigitalClassicVario
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import FilterErrorCode
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -21,6 +21,9 @@ DEFAULT_STT_MODEL = "scribe_v1"
|
||||
DEFAULT_STYLE = 0
|
||||
DEFAULT_USE_SPEAKER_BOOST = True
|
||||
|
||||
MAX_REQUEST_IDS = 3
|
||||
MODELS_PREVIOUS_INFO_NOT_SUPPORTED = ("eleven_v3",)
|
||||
|
||||
STT_LANGUAGES = [
|
||||
"af-ZA", # Afrikaans
|
||||
"am-ET", # Amharic
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["elevenlabs"],
|
||||
"requirements": ["elevenlabs==2.3.0"]
|
||||
"requirements": ["elevenlabs==2.3.0", "sentence-stream==1.2.0"]
|
||||
}
|
||||
|
||||
@@ -85,4 +85,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
strict-typing: todo
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import asyncio
|
||||
from collections import deque
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
from elevenlabs.core import ApiError
|
||||
from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings
|
||||
from sentence_stream import SentenceBoundaryDetector
|
||||
|
||||
from homeassistant.components.tts import (
|
||||
ATTR_VOICE,
|
||||
TextToSpeechEntity,
|
||||
TTSAudioRequest,
|
||||
TTSAudioResponse,
|
||||
TtsAudioType,
|
||||
Voice,
|
||||
)
|
||||
@@ -35,10 +41,12 @@ from .const import (
|
||||
DEFAULT_STYLE,
|
||||
DEFAULT_USE_SPEAKER_BOOST,
|
||||
DOMAIN,
|
||||
MAX_REQUEST_IDS,
|
||||
MODELS_PREVIOUS_INFO_NOT_SUPPORTED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
PARALLEL_UPDATES = 6
|
||||
|
||||
|
||||
def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings:
|
||||
@@ -122,7 +130,12 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
self._attr_supported_languages = [
|
||||
lang.language_id for lang in self._model.languages or []
|
||||
]
|
||||
self._attr_default_language = self.supported_languages[0]
|
||||
# Use the first supported language as the default if available
|
||||
self._attr_default_language = (
|
||||
self._attr_supported_languages[0]
|
||||
if self._attr_supported_languages
|
||||
else "en"
|
||||
)
|
||||
|
||||
def async_get_supported_voices(self, language: str) -> list[Voice]:
|
||||
"""Return a list of supported voices for a language."""
|
||||
@@ -151,3 +164,151 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
||||
)
|
||||
raise HomeAssistantError(exc) from exc
|
||||
return "mp3", bytes_combined
|
||||
|
||||
async def async_stream_tts_audio(
|
||||
self, request: TTSAudioRequest
|
||||
) -> TTSAudioResponse:
|
||||
"""Generate speech from an incoming message."""
|
||||
_LOGGER.debug(
|
||||
"Getting TTS audio for language %s and options: %s",
|
||||
request.language,
|
||||
request.options,
|
||||
)
|
||||
return TTSAudioResponse("mp3", self._process_tts_stream(request))
|
||||
|
||||
async def _process_tts_stream(
|
||||
self, request: TTSAudioRequest
|
||||
) -> AsyncGenerator[bytes]:
|
||||
"""Generate speech from an incoming message."""
|
||||
text_stream = request.message_gen
|
||||
boundary_detector = SentenceBoundaryDetector()
|
||||
sentences: list[str] = []
|
||||
sentences_ready = asyncio.Event()
|
||||
sentences_complete = False
|
||||
|
||||
language_code: str | None = request.language
|
||||
voice_id = request.options.get(ATTR_VOICE, self._default_voice_id)
|
||||
model = request.options.get(ATTR_MODEL, self._model.model_id)
|
||||
|
||||
use_request_ids = model not in MODELS_PREVIOUS_INFO_NOT_SUPPORTED
|
||||
previous_request_ids: deque[str] = deque(maxlen=MAX_REQUEST_IDS)
|
||||
|
||||
base_stream_params = {
|
||||
"voice_id": voice_id,
|
||||
"model_id": model,
|
||||
"output_format": "mp3_44100_128",
|
||||
"voice_settings": self._voice_settings,
|
||||
}
|
||||
if language_code:
|
||||
base_stream_params["language_code"] = language_code
|
||||
|
||||
_LOGGER.debug("Starting TTS Stream with options: %s", base_stream_params)
|
||||
|
||||
async def _add_sentences() -> None:
|
||||
nonlocal sentences_complete
|
||||
|
||||
try:
|
||||
# Text chunks may not be on word or sentence boundaries
|
||||
async for text_chunk in text_stream:
|
||||
for sentence in boundary_detector.add_chunk(text_chunk):
|
||||
if not sentence.strip():
|
||||
continue
|
||||
|
||||
sentences.append(sentence)
|
||||
|
||||
if not sentences:
|
||||
continue
|
||||
|
||||
sentences_ready.set()
|
||||
|
||||
# Final sentence
|
||||
if text := boundary_detector.finish():
|
||||
sentences.append(text)
|
||||
finally:
|
||||
sentences_complete = True
|
||||
sentences_ready.set()
|
||||
|
||||
_add_sentences_task = self.hass.async_create_background_task(
|
||||
_add_sentences(), name="elevenlabs_tts_add_sentences"
|
||||
)
|
||||
|
||||
# Process new sentences as they're available, but synthesize the first
|
||||
# one immediately. While that's playing, synthesize (up to) the next 3
|
||||
# sentences. After that, synthesize all completed sentences as they're
|
||||
# available.
|
||||
sentence_schedule = [1, 3]
|
||||
while True:
|
||||
await sentences_ready.wait()
|
||||
|
||||
# Don't wait again if no more sentences are coming
|
||||
if not sentences_complete:
|
||||
sentences_ready.clear()
|
||||
|
||||
if not sentences:
|
||||
if sentences_complete:
|
||||
# Exit TTS loop
|
||||
_LOGGER.debug("No more sentences to process")
|
||||
break
|
||||
|
||||
# More sentences may be coming
|
||||
continue
|
||||
|
||||
new_sentences = sentences[:]
|
||||
sentences.clear()
|
||||
|
||||
while new_sentences:
|
||||
if sentence_schedule:
|
||||
max_sentences = sentence_schedule.pop(0)
|
||||
sentences_to_process = new_sentences[:max_sentences]
|
||||
new_sentences = new_sentences[len(sentences_to_process) :]
|
||||
else:
|
||||
# Process all available sentences together
|
||||
sentences_to_process = new_sentences[:]
|
||||
new_sentences.clear()
|
||||
|
||||
# Combine all new sentences completed to this point
|
||||
text = " ".join(sentences_to_process).strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Build kwargs common to both modes
|
||||
kwargs = base_stream_params | {
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Provide previous_request_ids if supported.
|
||||
if previous_request_ids:
|
||||
# Send previous request ids.
|
||||
kwargs["previous_request_ids"] = list(previous_request_ids)
|
||||
|
||||
# Synthesize audio while text chunks are still being accumulated
|
||||
_LOGGER.debug("Synthesizing TTS for text: %s", text)
|
||||
try:
|
||||
async with self._client.text_to_speech.with_raw_response.stream(
|
||||
**kwargs
|
||||
) as stream:
|
||||
async for chunk_bytes in stream.data:
|
||||
yield chunk_bytes
|
||||
|
||||
if use_request_ids:
|
||||
if (rid := stream.headers.get("request-id")) is not None:
|
||||
previous_request_ids.append(rid)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"No request-id returned from server; clearing previous requests"
|
||||
)
|
||||
previous_request_ids.clear()
|
||||
except ApiError as exc:
|
||||
_LOGGER.warning(
|
||||
"Error during processing of TTS request %s", exc, exc_info=True
|
||||
)
|
||||
_add_sentences_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await _add_sentences_task
|
||||
raise HomeAssistantError(exc) from exc
|
||||
|
||||
# Capture and store server request-id for next calls (only when supported)
|
||||
_LOGGER.debug("Completed TTS stream for text: %s", text)
|
||||
|
||||
_LOGGER.debug("Completed TTS stream")
|
||||
|
||||
@@ -16,7 +16,9 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.recorder import reset_detected
|
||||
from homeassistant.components.sensor.recorder import ( # pylint: disable=hass-component-root-import
|
||||
reset_detected,
|
||||
)
|
||||
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
|
||||
@@ -10,8 +10,8 @@ from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.components.sensor.const import SensorStateClass
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, cast
|
||||
from aioesphomeapi import (
|
||||
ClimateAction,
|
||||
ClimateFanMode,
|
||||
ClimateFeature,
|
||||
ClimateInfo,
|
||||
ClimateMode,
|
||||
ClimatePreset,
|
||||
@@ -134,12 +135,16 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_feature_flags = ClimateFeature(0)
|
||||
|
||||
@callback
|
||||
def _on_static_info_update(self, static_info: EntityInfo) -> None:
|
||||
"""Set attrs from static info."""
|
||||
super()._on_static_info_update(static_info)
|
||||
static_info = self._static_info
|
||||
self._feature_flags = ClimateFeature(
|
||||
static_info.supported_feature_flags_compat(self._api_version)
|
||||
)
|
||||
self._attr_precision = self._get_precision()
|
||||
self._attr_hvac_modes = [
|
||||
_CLIMATE_MODES.from_esphome(mode) for mode in static_info.supported_modes
|
||||
@@ -163,11 +168,18 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
self._attr_max_temp = static_info.visual_max_temperature
|
||||
self._attr_min_humidity = round(static_info.visual_min_humidity)
|
||||
self._attr_max_humidity = round(static_info.visual_max_humidity)
|
||||
features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if static_info.supports_two_point_target_temperature:
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
if static_info.supports_target_humidity:
|
||||
features = ClimateEntityFeature(0)
|
||||
if self._feature_flags & ClimateFeature.SUPPORTS_TARGET_HUMIDITY:
|
||||
features |= ClimateEntityFeature.TARGET_HUMIDITY
|
||||
if self._feature_flags & ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE:
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
else:
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if (
|
||||
self._feature_flags
|
||||
& ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
):
|
||||
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.fan_modes:
|
||||
@@ -203,7 +215,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return current action."""
|
||||
# HA has no support feature field for hvac_action
|
||||
if not self._static_info.supports_action:
|
||||
if not self._feature_flags & ClimateFeature.SUPPORTS_ACTION:
|
||||
return None
|
||||
return _CLIMATE_ACTIONS.from_esphome(self._state.action)
|
||||
|
||||
@@ -233,7 +245,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
@esphome_float_state_property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if not self._static_info.supports_current_temperature:
|
||||
if not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_TEMPERATURE:
|
||||
return None
|
||||
return self._state.current_temperature
|
||||
|
||||
@@ -242,7 +254,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity."""
|
||||
if (
|
||||
not self._static_info.supports_current_humidity
|
||||
(not self._feature_flags & ClimateFeature.SUPPORTS_CURRENT_HUMIDITY)
|
||||
or (val := self._state.current_humidity) is None
|
||||
or not isfinite(val)
|
||||
):
|
||||
@@ -254,7 +266,11 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if (
|
||||
not self._static_info.supports_two_point_target_temperature
|
||||
not self._feature_flags
|
||||
& (
|
||||
ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
|
||||
| ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
)
|
||||
and self.hvac_mode != HVACMode.AUTO
|
||||
):
|
||||
return self._state.target_temperature
|
||||
@@ -295,7 +311,10 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
||||
cast(HVACMode, kwargs[ATTR_HVAC_MODE])
|
||||
)
|
||||
if ATTR_TEMPERATURE in kwargs:
|
||||
if not self._static_info.supports_two_point_target_temperature:
|
||||
if not self._feature_flags & (
|
||||
ClimateFeature.REQUIRES_TWO_POINT_TARGET_TEMPERATURE
|
||||
| ClimateFeature.SUPPORTS_TWO_POINT_TARGET_TEMPERATURE
|
||||
):
|
||||
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
|
||||
else:
|
||||
hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode
|
||||
|
||||
@@ -542,7 +542,16 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check if Z-Wave capabilities are present and start discovery flow
|
||||
next_flow_id: str | None = None
|
||||
if self._device_info.zwave_proxy_feature_flags:
|
||||
# If the zwave_home_id is not set, we don't know if it's a fresh
|
||||
# adapter, or the cable is just unplugged. So only start
|
||||
# the zwave_js config flow automatically if there is a
|
||||
# zwave_home_id present. If it's a fresh adapter, the manager
|
||||
# will handle starting the flow once it gets the home id changed
|
||||
# request from the ESPHome device.
|
||||
if (
|
||||
self._device_info.zwave_proxy_feature_flags
|
||||
and self._device_info.zwave_home_id
|
||||
):
|
||||
assert self._connected_address is not None
|
||||
assert self._port is not None
|
||||
|
||||
@@ -559,7 +568,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
data=ESPHomeServiceInfo(
|
||||
name=self._device_info.name,
|
||||
zwave_home_id=self._device_info.zwave_home_id or None,
|
||||
zwave_home_id=self._device_info.zwave_home_id,
|
||||
ip_address=self._connected_address,
|
||||
port=self._port,
|
||||
noise_psk=self._noise_psk,
|
||||
|
||||
@@ -491,13 +491,30 @@ class RuntimeEntryData:
|
||||
|
||||
assert self.client.connected_address
|
||||
|
||||
# If the device does not have a zwave_home_id, it means
|
||||
# either the Z-Wave controller has never been connected
|
||||
# to the ESPHome device, or the Z-Wave controller has
|
||||
# never been provisioned with a home ID (brand new).
|
||||
# Since we cannot tell the difference, and it could
|
||||
# just be the cable is unplugged we only
|
||||
# automatically start the flow if we have a home ID.
|
||||
if not device_info.zwave_home_id:
|
||||
return
|
||||
|
||||
self.async_create_zwave_js_flow(hass, device_info, device_info.zwave_home_id)
|
||||
|
||||
def async_create_zwave_js_flow(
|
||||
self, hass: HomeAssistant, device_info: DeviceInfo, zwave_home_id: int
|
||||
) -> None:
|
||||
"""Create a zwave_js config flow for a Z-Wave JS Proxy device."""
|
||||
assert self.client.connected_address is not None
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
"zwave_js",
|
||||
{"source": config_entries.SOURCE_ESPHOME},
|
||||
ESPHomeServiceInfo(
|
||||
name=device_info.name,
|
||||
zwave_home_id=device_info.zwave_home_id or None,
|
||||
zwave_home_id=zwave_home_id,
|
||||
ip_address=self.client.connected_address,
|
||||
port=self.client.port,
|
||||
noise_psk=self.client.noise_psk,
|
||||
|
||||
@@ -6,6 +6,7 @@ import base64
|
||||
from functools import partial
|
||||
import logging
|
||||
import secrets
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
from aioesphomeapi import (
|
||||
@@ -22,6 +23,8 @@ from aioesphomeapi import (
|
||||
RequiresEncryptionAPIError,
|
||||
UserService,
|
||||
UserServiceArgType,
|
||||
ZWaveProxyRequest,
|
||||
ZWaveProxyRequestType,
|
||||
parse_log_message,
|
||||
)
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -44,12 +47,18 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
TemplateError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
json,
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
@@ -84,6 +93,8 @@ from .encryption_key_storage import async_get_encryption_key_storage
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
|
||||
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
|
||||
UNPACK_UINT32_BE = struct.Struct(">I").unpack_from
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import SubscribeLogsResponse # type: ignore[attr-defined] # noqa: I001
|
||||
@@ -268,11 +279,32 @@ class ESPHomeManager:
|
||||
elif self.entry.options.get(
|
||||
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
|
||||
):
|
||||
hass.async_create_task(
|
||||
hass.services.async_call(
|
||||
domain, service_name, service_data, blocking=True
|
||||
call_id = service.call_id
|
||||
if call_id and service.wants_response:
|
||||
# Service call with response expected
|
||||
self.entry.async_create_task(
|
||||
hass,
|
||||
self._handle_service_call_with_response(
|
||||
domain,
|
||||
service_name,
|
||||
service_data,
|
||||
call_id,
|
||||
service.response_template,
|
||||
),
|
||||
)
|
||||
elif call_id:
|
||||
# Service call without response but needs success/failure notification
|
||||
self.entry.async_create_task(
|
||||
hass,
|
||||
self._handle_service_call_with_notification(
|
||||
domain, service_name, service_data, call_id
|
||||
),
|
||||
)
|
||||
else:
|
||||
# Fire and forget service call
|
||||
self.entry.async_create_task(
|
||||
hass, hass.services.async_call(domain, service_name, service_data)
|
||||
)
|
||||
)
|
||||
else:
|
||||
device_info = self.entry_data.device_info
|
||||
assert device_info is not None
|
||||
@@ -298,6 +330,98 @@ class ESPHomeManager:
|
||||
service_data,
|
||||
)
|
||||
|
||||
async def _handle_service_call_with_response(
|
||||
self,
|
||||
domain: str,
|
||||
service_name: str,
|
||||
service_data: dict,
|
||||
call_id: int,
|
||||
response_template: str | None = None,
|
||||
) -> None:
|
||||
"""Handle service call that expects a response and send response back to ESPHome."""
|
||||
try:
|
||||
# Call the service with response capture enabled
|
||||
action_response = await self.hass.services.async_call(
|
||||
domain=domain,
|
||||
service=service_name,
|
||||
service_data=service_data,
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
if response_template:
|
||||
try:
|
||||
# Render response template
|
||||
tmpl = Template(response_template, self.hass)
|
||||
response = tmpl.async_render(
|
||||
variables={"response": action_response},
|
||||
strict=True,
|
||||
)
|
||||
response_dict = {"response": response}
|
||||
|
||||
except TemplateError as ex:
|
||||
raise HomeAssistantError(
|
||||
f"Error rendering response template: {ex}"
|
||||
) from ex
|
||||
else:
|
||||
response_dict = {"response": action_response}
|
||||
|
||||
# JSON encode response data for ESPHome
|
||||
response_data = json.json_bytes(response_dict)
|
||||
|
||||
except (
|
||||
ServiceNotFound,
|
||||
ServiceValidationError,
|
||||
vol.Invalid,
|
||||
HomeAssistantError,
|
||||
) as ex:
|
||||
self._send_service_call_response(
|
||||
call_id, success=False, error_message=str(ex), response_data=b""
|
||||
)
|
||||
|
||||
else:
|
||||
# Send success response back to ESPHome
|
||||
self._send_service_call_response(
|
||||
call_id=call_id,
|
||||
success=True,
|
||||
error_message="",
|
||||
response_data=response_data,
|
||||
)
|
||||
|
||||
async def _handle_service_call_with_notification(
|
||||
self, domain: str, service_name: str, service_data: dict, call_id: int
|
||||
) -> None:
|
||||
"""Handle service call that needs success/failure notification."""
|
||||
try:
|
||||
await self.hass.services.async_call(
|
||||
domain, service_name, service_data, blocking=True
|
||||
)
|
||||
except (ServiceNotFound, ServiceValidationError, vol.Invalid) as ex:
|
||||
self._send_service_call_response(call_id, False, str(ex), b"")
|
||||
else:
|
||||
self._send_service_call_response(call_id, True, "", b"")
|
||||
|
||||
def _send_service_call_response(
|
||||
self,
|
||||
call_id: int,
|
||||
success: bool,
|
||||
error_message: str,
|
||||
response_data: bytes,
|
||||
) -> None:
|
||||
"""Send service call response back to ESPHome device."""
|
||||
_LOGGER.debug(
|
||||
"Service call response for call_id %s: success=%s, error=%s",
|
||||
call_id,
|
||||
success,
|
||||
error_message,
|
||||
)
|
||||
self.cli.send_homeassistant_action_response(
|
||||
call_id,
|
||||
success,
|
||||
error_message,
|
||||
response_data,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _send_home_assistant_state(
|
||||
self, entity_id: str, attribute: str | None, state: State | None
|
||||
@@ -557,6 +681,11 @@ class ESPHomeManager:
|
||||
)
|
||||
entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE)
|
||||
|
||||
if device_info.zwave_proxy_feature_flags:
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
|
||||
)
|
||||
|
||||
cli.subscribe_home_assistant_states_and_services(
|
||||
on_state=entry_data.async_update_state,
|
||||
on_service_call=self.async_on_service_call,
|
||||
@@ -568,6 +697,25 @@ class ESPHomeManager:
|
||||
_async_check_firmware_version(hass, device_info, api_version)
|
||||
_async_check_using_api_password(hass, device_info, bool(self.password))
|
||||
|
||||
def _async_zwave_proxy_request(self, request: ZWaveProxyRequest) -> None:
|
||||
"""Handle a request to create a zwave_js config flow."""
|
||||
if request.type != ZWaveProxyRequestType.HOME_ID_CHANGE:
|
||||
return
|
||||
# ESPHome will send a home id change on every connection
|
||||
# if the Z-Wave controller is connected to the ESPHome device
|
||||
# so we know for sure that the Z-Wave controller is connected
|
||||
# when we get the message. This makes it safe to start
|
||||
# the zwave_js config flow automatically even if the zwave_home_id
|
||||
# is 0 (not yet provisioned) as we know for sure the controller
|
||||
# is connected to the ESPHome device and do not have to guess
|
||||
# if it's a broken connection or Z-Wave controller or a not
|
||||
# yet provisioned controller.
|
||||
zwave_home_id: int = UNPACK_UINT32_BE(request.data[0:4])[0]
|
||||
assert self.entry_data.device_info is not None
|
||||
self.entry_data.async_create_zwave_js_flow(
|
||||
self.hass, self.entry_data.device_info, zwave_home_id
|
||||
)
|
||||
|
||||
async def on_disconnect(self, expected_disconnect: bool) -> None:
|
||||
"""Run disconnect callbacks on API disconnect."""
|
||||
entry_data = self.entry_data
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==42.0.0",
|
||||
"aioesphomeapi==42.2.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import replace
|
||||
|
||||
from aioesphomeapi import EntityInfo, SelectInfo, SelectState
|
||||
|
||||
from homeassistant.components.assist_pipeline.select import (
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
AssistPipelineSelect,
|
||||
VadSensitivitySelect,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@mib1185"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/feedreader",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["feedparser", "sgmllib3k"],
|
||||
"requirements": ["feedparser==6.0.12"]
|
||||
|
||||
@@ -19,7 +19,9 @@ from homeassistant.components.ffmpeg import (
|
||||
FFmpegManager,
|
||||
get_ffmpeg_manager,
|
||||
)
|
||||
from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor
|
||||
from homeassistant.components.ffmpeg_motion.binary_sensor import ( # pylint: disable=hass-component-root-import
|
||||
FFmpegBinarySensor,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
42
homeassistant/components/fing/__init__.py
Normal file
42
homeassistant/components/fing/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""The Fing integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
from .coordinator import FingConfigEntry, FingDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: FingConfigEntry) -> bool:
|
||||
"""Set up the Fing component."""
|
||||
|
||||
coordinator = FingDataUpdateCoordinator(hass, config_entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
if coordinator.data.network_id is None:
|
||||
_LOGGER.warning(
|
||||
"Skip setting up Fing integration; Received an empty NetworkId from the request - Check if the API version is the latest"
|
||||
)
|
||||
raise ConfigEntryError(
|
||||
"The Agent's API version is outdated. Please update the agent to the latest version."
|
||||
)
|
||||
|
||||
config_entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: FingConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
114
homeassistant/components/fing/config_flow.py
Normal file
114
homeassistant/components/fing/config_flow.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Config flow file."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fing_agent_api import FingAgent
|
||||
import httpx
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FingConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Fing config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Set up user step."""
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
devices_response = None
|
||||
agent_info_response = None
|
||||
|
||||
self._async_abort_entries_match(
|
||||
{CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS]}
|
||||
)
|
||||
|
||||
fing_api = FingAgent(
|
||||
ip=user_input[CONF_IP_ADDRESS],
|
||||
port=int(user_input[CONF_PORT]),
|
||||
key=user_input[CONF_API_KEY],
|
||||
)
|
||||
|
||||
try:
|
||||
devices_response = await fing_api.get_devices()
|
||||
|
||||
with suppress(httpx.ConnectError):
|
||||
# The suppression is needed because the get_agent_info method isn't available for desktop agents
|
||||
agent_info_response = await fing_api.get_agent_info()
|
||||
|
||||
except httpx.NetworkError as _:
|
||||
errors["base"] = "cannot_connect"
|
||||
except httpx.TimeoutException as _:
|
||||
errors["base"] = "timeout_connect"
|
||||
except httpx.HTTPStatusError as exception:
|
||||
description_placeholders["message"] = (
|
||||
f"{exception.response.status_code} - {exception.response.reason_phrase}"
|
||||
)
|
||||
if exception.response.status_code == 401:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
errors["base"] = "http_status_error"
|
||||
except httpx.InvalidURL as _:
|
||||
errors["base"] = "url_error"
|
||||
except (
|
||||
httpx.HTTPError,
|
||||
httpx.CookieConflict,
|
||||
httpx.StreamError,
|
||||
) as ex:
|
||||
_LOGGER.error("Unexpected exception: %s", ex)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if (
|
||||
devices_response.network_id is not None
|
||||
and len(devices_response.network_id) > 0
|
||||
):
|
||||
agent_name = user_input.get(CONF_IP_ADDRESS)
|
||||
upnp_available = False
|
||||
if agent_info_response is not None:
|
||||
upnp_available = True
|
||||
agent_name = agent_info_response.agent_id
|
||||
await self.async_set_unique_id(agent_info_response.agent_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
data = {
|
||||
CONF_IP_ADDRESS: user_input[CONF_IP_ADDRESS],
|
||||
CONF_PORT: user_input[CONF_PORT],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
UPNP_AVAILABLE: upnp_available,
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"Fing Agent {agent_name}",
|
||||
data=data,
|
||||
)
|
||||
|
||||
return self.async_abort(reason="api_version_error")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_IP_ADDRESS): str,
|
||||
vol.Required(CONF_PORT, default="49090"): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
4
homeassistant/components/fing/const.py
Normal file
4
homeassistant/components/fing/const.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""Const for the Fing integration."""
|
||||
|
||||
DOMAIN = "fing"
|
||||
UPNP_AVAILABLE = "upnp_available"
|
||||
85
homeassistant/components/fing/coordinator.py
Normal file
85
homeassistant/components/fing/coordinator.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""DataUpdateCoordinator for Fing integration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from fing_agent_api import FingAgent
|
||||
from fing_agent_api.models import AgentInfoResponse, Device
|
||||
import httpx
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPNP_AVAILABLE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FingConfigEntry = ConfigEntry[FingDataUpdateCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FingDataObject:
|
||||
"""Fing Data Object."""
|
||||
|
||||
network_id: str | None = None
|
||||
agent_info: AgentInfoResponse | None = None
|
||||
devices: dict[str, Device] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
|
||||
"""Class to manage fetching data from Fing Agent."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FingConfigEntry) -> None:
|
||||
"""Initialize global Fing updater."""
|
||||
self._fing = FingAgent(
|
||||
ip=config_entry.data[CONF_IP_ADDRESS],
|
||||
port=int(config_entry.data[CONF_PORT]),
|
||||
key=config_entry.data[CONF_API_KEY],
|
||||
)
|
||||
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
|
||||
update_interval = timedelta(seconds=30)
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=update_interval,
|
||||
config_entry=config_entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> FingDataObject:
|
||||
"""Fetch data from Fing Agent."""
|
||||
device_response = None
|
||||
agent_info_response = None
|
||||
try:
|
||||
device_response = await self._fing.get_devices()
|
||||
|
||||
if self._upnp_available:
|
||||
agent_info_response = await self._fing.get_agent_info()
|
||||
|
||||
except httpx.NetworkError as err:
|
||||
raise UpdateFailed("Failed to connect") from err
|
||||
except httpx.TimeoutException as err:
|
||||
raise UpdateFailed("Timeout establishing connection") from err
|
||||
except httpx.HTTPStatusError as err:
|
||||
if err.response.status_code == 401:
|
||||
raise UpdateFailed("Invalid API key") from err
|
||||
raise UpdateFailed(
|
||||
f"Http request failed -> {err.response.status_code} - {err.response.reason_phrase}"
|
||||
) from err
|
||||
except httpx.InvalidURL as err:
|
||||
raise UpdateFailed("Invalid hostname or IP address") from err
|
||||
except (
|
||||
httpx.HTTPError,
|
||||
httpx.CookieConflict,
|
||||
httpx.StreamError,
|
||||
) as err:
|
||||
raise UpdateFailed("Unexpected error from HTTP request") from err
|
||||
else:
|
||||
return FingDataObject(
|
||||
device_response.network_id,
|
||||
agent_info_response,
|
||||
{device.mac: device for device in device_response.devices},
|
||||
)
|
||||
127
homeassistant/components/fing/device_tracker.py
Normal file
127
homeassistant/components/fing/device_tracker.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Platform for Device tracker integration."""
|
||||
|
||||
from fing_agent_api.models import Device
|
||||
|
||||
from homeassistant.components.device_tracker import ScannerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FingConfigEntry
|
||||
from .coordinator import FingDataUpdateCoordinator
|
||||
from .utils import get_icon_from_type
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FingConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add sensors for passed config_entry in HA."""
|
||||
coordinator = config_entry.runtime_data
|
||||
entity_registry = er.async_get(hass)
|
||||
tracked_devices: set[str] = set()
|
||||
|
||||
@callback
|
||||
def add_entities() -> None:
|
||||
latest_devices = set(coordinator.data.devices.keys())
|
||||
|
||||
devices_to_remove = tracked_devices - set(latest_devices)
|
||||
devices_to_add = set(latest_devices) - tracked_devices
|
||||
|
||||
entities_to_remove = []
|
||||
for entity_entry in entity_registry.entities.values():
|
||||
if entity_entry.config_entry_id != config_entry.entry_id:
|
||||
continue
|
||||
try:
|
||||
_, mac = entity_entry.unique_id.rsplit("-", 1)
|
||||
if mac in devices_to_remove:
|
||||
entities_to_remove.append(entity_entry.entity_id)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for entity_id in entities_to_remove:
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
entities_to_add = []
|
||||
for mac_addr in devices_to_add:
|
||||
device = coordinator.data.devices[mac_addr]
|
||||
entities_to_add.append(FingTrackedDevice(coordinator, device))
|
||||
|
||||
tracked_devices.clear()
|
||||
tracked_devices.update(latest_devices)
|
||||
async_add_entities(entities_to_add)
|
||||
|
||||
add_entities()
|
||||
config_entry.async_on_unload(coordinator.async_add_listener(add_entities))
|
||||
|
||||
|
||||
class FingTrackedDevice(CoordinatorEntity[FingDataUpdateCoordinator], ScannerEntity):
|
||||
"""Represent a tracked device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: FingDataUpdateCoordinator, device: Device) -> None:
|
||||
"""Set up FingDevice entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device = device
|
||||
agent_id = coordinator.data.network_id
|
||||
if coordinator.data.agent_info is not None:
|
||||
agent_id = coordinator.data.agent_info.agent_id
|
||||
|
||||
self._attr_mac_address = self._device.mac
|
||||
self._attr_unique_id = f"{agent_id}-{self._attr_mac_address}"
|
||||
self._attr_name = self._device.name
|
||||
self._attr_icon = get_icon_from_type(self._device.type)
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected to the network."""
|
||||
return self._device.active
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Return the primary ip address of the device."""
|
||||
return self._device.ip[0] if self._device.ip else None
|
||||
|
||||
@property
|
||||
def entity_registry_enabled_default(self) -> bool:
|
||||
"""Enable entity by default."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
"""Return the unique ID of the entity."""
|
||||
return self._attr_unique_id
|
||||
|
||||
def check_for_updates(self, new_device: Device) -> bool:
|
||||
"""Return true if the device has updates."""
|
||||
new_device_ip = new_device.ip[0] if new_device.ip else None
|
||||
current_device_ip = self._device.ip[0] if self._device.ip else None
|
||||
|
||||
return (
|
||||
current_device_ip != new_device_ip
|
||||
or self._device.active != new_device.active
|
||||
or self._device.type != new_device.type
|
||||
or self._attr_name != new_device.name
|
||||
or self._attr_icon != get_icon_from_type(new_device.type)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
updated_device_data = self.coordinator.data.devices.get(self._device.mac)
|
||||
if updated_device_data is not None and self.check_for_updates(
|
||||
updated_device_data
|
||||
):
|
||||
self._device = updated_device_data
|
||||
self._attr_name = updated_device_data.name
|
||||
self._attr_icon = get_icon_from_type(updated_device_data.type)
|
||||
er.async_get(self.hass).async_update_entity(
|
||||
entity_id=self.entity_id,
|
||||
original_name=self._attr_name,
|
||||
original_icon=self._attr_icon,
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
10
homeassistant/components/fing/manifest.json
Normal file
10
homeassistant/components/fing/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "fing",
|
||||
"name": "Fing",
|
||||
"codeowners": ["@Lorenzo-Gasparini"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/fing",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fing_agent_api==1.0.3"]
|
||||
}
|
||||
72
homeassistant/components/fing/quality_scale.yaml
Normal file
72
homeassistant/components/fing/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: The integration has no actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: There are no actions in Fing integration.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Fing integration entities do not use events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: The integration has no actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: The integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: done
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: The integration creates only device tracker entities
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
31
homeassistant/components/fing/strings.json
Normal file
31
homeassistant/components/fing/strings.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up Fing agent",
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"port": "[%key:common::config_flow::data::port%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "IP address of the Fing agent.",
|
||||
"port": "Port number of the Fing API.",
|
||||
"api_key": "API key used to authenticate with the Fing API."
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"url_error": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"http_status_error": "HTTP request failed: {message}"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"api_version_error": "Your agent is using an outdated API version. The required 'network_id' parameter is missing. Please update to the latest API version."
|
||||
}
|
||||
}
|
||||
}
|
||||
85
homeassistant/components/fing/utils.py
Normal file
85
homeassistant/components/fing/utils.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Utils functions."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DeviceType(Enum):
|
||||
"""Device types enum."""
|
||||
|
||||
GENERIC = "mdi:lan-connect"
|
||||
MOBILE = PHONE = "mdi:cellphone"
|
||||
TABLET = IPOD = EREADER = "mdi:tablet"
|
||||
WATCH = WEARABLE = "mdi:watch"
|
||||
CAR = AUTOMOTIVE = "mdi:car-back"
|
||||
MEDIA_PLAYER = "mdi:volume-high"
|
||||
TELEVISION = "mdi:television"
|
||||
GAME_CONSOLE = "mdi:nintendo-game-boy"
|
||||
STREAMING_DONGLE = "mdi:cast"
|
||||
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
|
||||
DISC_PLAYER = "mdi:disk-player"
|
||||
REMOTE_CONTROL = "mdi:remote-tv"
|
||||
RADIO = "mdi:radio"
|
||||
PHOTO_CAMERA = PHOTOS = "mdi:camera"
|
||||
MICROPHONE = VOICE_CONTROL = "mdi:microphone"
|
||||
PROJECTOR = "mdi:projector"
|
||||
COMPUTER = DESKTOP = "mdi:desktop-tower"
|
||||
LAPTOP = "mdi:laptop"
|
||||
PRINTER = "mdi:printer"
|
||||
SCANNER = "mdi:scanner"
|
||||
POS = "mdi:printer-pos"
|
||||
CLOCK = "mdi:clock"
|
||||
BARCODE = "mdi:barcode"
|
||||
SURVEILLANCE_CAMERA = BABY_MONITOR = PET_MONITOR = "mdi:cctv"
|
||||
POE_PLUG = HEALTH_MONITOR = SMART_HOME = SMART_METER = APPLIANCE = SLEEP = (
|
||||
"mdi:home-automation"
|
||||
)
|
||||
SMART_PLUG = "mdi:power-plug"
|
||||
LIGHT = "mdi:lightbulb"
|
||||
THERMOSTAT = HEATING = "mdi:home-thermometer"
|
||||
POWER_SYSTEM = ENERGY = "mdi:lightning-bolt"
|
||||
SOLAR_PANEL = "mdi:solar-power"
|
||||
WASHER = "mdi:washing-machine"
|
||||
FRIDGE = "mdi:fridge"
|
||||
CLEANER = "mdi:vacuum"
|
||||
GARAGE = "mdi:garage"
|
||||
SPRINKLER = "mdi:sprinkler"
|
||||
BELL = "mdi:doorbell"
|
||||
KEY_LOCK = "mdi:lock-smart"
|
||||
CONTROL_PANEL = SMART_CONTROLLER = "mdi:alarm-panel"
|
||||
SCALE = "mdi:scale-bathroom"
|
||||
TOY = "mdi:teddy-bear"
|
||||
ROBOT = "mdi:robot"
|
||||
WEATHER = "mdi:weather-cloudy"
|
||||
ALARM = "mdi:alarm-light"
|
||||
MOTION_DETECTOR = "mdi:motion-sensor"
|
||||
SMOKE = HUMIDITY = SENSOR = DOMOTZ_BOX = FINGBOX = "mdi:smoke-detector"
|
||||
ROUTER = MODEM = GATEWAY = FIREWALL = VPN = SMALL_CELL = "mdi:router-network"
|
||||
WIFI = WIFI_EXTENDER = "mdi:wifi"
|
||||
NAS_STORAGE = "mdi:nas"
|
||||
SWITCH = "mdi:switch"
|
||||
USB = "mdi:usb"
|
||||
CLOUD = "mdi:cloud"
|
||||
BATTERY = "mdi:battery"
|
||||
NETWORK_APPLIANCE = "mdi:network"
|
||||
VIRTUAL_MACHINE = MAIL_SERVER = FILE_SERVER = PROXY_SERVER = WEB_SERVER = (
|
||||
DOMAIN_SERVER
|
||||
) = COMMUNICATION = "mdi:monitor"
|
||||
SERVER = "mdi:server"
|
||||
TERMINAL = "mdi:console"
|
||||
DATABASE = "mdi:database"
|
||||
RASPBERRY = ARDUINO = "mdi:raspberry-pi"
|
||||
PROCESSOR = CIRCUIT_CARD = RFID = "mdi:chip"
|
||||
INDUSTRIAL = "mdi:factory"
|
||||
MEDICAL = "mdi:medical-bag"
|
||||
VOIP = CONFERENCING = "mdi:phone-voip"
|
||||
FITNESS = "mdi:dumbbell"
|
||||
POOL = "mdi:pool"
|
||||
SECURITY_SYSTEM = "mdi:security"
|
||||
|
||||
|
||||
def get_icon_from_type(type: str) -> str:
|
||||
"""Return the right icon based on the type."""
|
||||
try:
|
||||
return DeviceType[type].value
|
||||
except (ValueError, KeyError):
|
||||
return "mdi:lan-connect"
|
||||
26
homeassistant/components/firefly_iii/diagnostics.py
Normal file
26
homeassistant/components/firefly_iii/diagnostics.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Diagnostics for the Firefly III integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import FireflyConfigEntry
|
||||
from .coordinator import FireflyDataUpdateCoordinator
|
||||
|
||||
TO_REDACT = [CONF_API_KEY, CONF_URL]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: FireflyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: FireflyDataUpdateCoordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"data": {"primary_currency": coordinator.data.primary_currency.to_dict()},
|
||||
}
|
||||
@@ -4,8 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from pyfirefly.models import Account, Category
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass, StateType
|
||||
from homeassistant.components.sensor.const import SensorDeviceClass
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -111,7 +111,12 @@ class FlumeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"api_url": "https://portal.flumetech.com/settings#token"
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
|
||||
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at {api_url}",
|
||||
"title": "Connect to your Flume account",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
|
||||
@@ -6,9 +6,8 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.camera import CameraEntityFeature
|
||||
from homeassistant.components.ffmpeg.camera import (
|
||||
CONF_EXTRA_ARGUMENTS,
|
||||
CONF_INPUT,
|
||||
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT
|
||||
from homeassistant.components.ffmpeg.camera import ( # pylint: disable=hass-component-root-import
|
||||
DEFAULT_ARGUMENTS,
|
||||
FFmpegCamera,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers import aiohttp_client
|
||||
from .const import DOMAIN
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
API_KEY_URL = "https://freedompro.eu/"
|
||||
|
||||
|
||||
class Hub:
|
||||
@@ -53,7 +54,11 @@ class FreedomProConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Show the setup form to the user."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders={
|
||||
"api_key_url": API_KEY_URL,
|
||||
},
|
||||
)
|
||||
|
||||
errors = {}
|
||||
@@ -68,7 +73,12 @@ class FreedomProConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(title="Freedompro", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"api_key_url": API_KEY_URL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"description": "Please enter the API key obtained from https://home.freedompro.eu",
|
||||
"description": "Please enter the API key obtained from {api_key_url}",
|
||||
"title": "Freedompro API key"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
@@ -16,6 +17,7 @@ from fritzconnection.core.exceptions import (
|
||||
FritzConnectionException,
|
||||
FritzSecurityError,
|
||||
)
|
||||
from fritzconnection.lib.fritzcall import FritzCall
|
||||
from fritzconnection.lib.fritzhosts import FritzHosts
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
from fritzconnection.lib.fritzwlan import FritzGuestWLAN
|
||||
@@ -120,6 +122,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_guest_wifi: FritzGuestWLAN = None
|
||||
self.fritz_hosts: FritzHosts = None
|
||||
self.fritz_status: FritzStatus = None
|
||||
self.fritz_call: FritzCall = None
|
||||
self.host = host
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
self.mesh_wifi_uplink = False
|
||||
@@ -183,6 +186,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_hosts = FritzHosts(fc=self.connection)
|
||||
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
|
||||
self.fritz_status = FritzStatus(fc=self.connection)
|
||||
self.fritz_call = FritzCall(fc=self.connection)
|
||||
info = self.fritz_status.get_device_info()
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -617,6 +621,14 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_guest_wifi.set_password, password, length
|
||||
)
|
||||
|
||||
async def async_trigger_dial(self, number: str, max_ring_seconds: int) -> None:
|
||||
"""Trigger service to dial a number."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.fritz_call.dial, number)
|
||||
await asyncio.sleep(max_ring_seconds)
|
||||
finally:
|
||||
await self.hass.async_add_executor_job(self.fritz_call.hangup)
|
||||
|
||||
async def async_trigger_cleanup(self) -> None:
|
||||
"""Trigger device trackers cleanup."""
|
||||
_LOGGER.debug("Device tracker cleanup triggered")
|
||||
|
||||
@@ -62,6 +62,9 @@
|
||||
},
|
||||
"set_guest_wifi_password": {
|
||||
"service": "mdi:form-textbox-password"
|
||||
},
|
||||
"dial": {
|
||||
"service": "mdi:phone-dial"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"config_flow": true,
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/fritz",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"requirements": ["fritzconnection[qr]==1.15.0", "xmltodict==0.13.0"],
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from fritzconnection.core.exceptions import (
|
||||
FritzActionError,
|
||||
FritzActionFailedError,
|
||||
FritzConnectionException,
|
||||
FritzServiceError,
|
||||
)
|
||||
@@ -27,6 +28,14 @@ SERVICE_SCHEMA_SET_GUEST_WIFI_PW = vol.Schema(
|
||||
vol.Optional("length"): vol.Range(min=8, max=63),
|
||||
}
|
||||
)
|
||||
SERVICE_DIAL = "dial"
|
||||
SERVICE_SCHEMA_DIAL = vol.Schema(
|
||||
{
|
||||
vol.Required("device_id"): str,
|
||||
vol.Required("number"): str,
|
||||
vol.Required("max_ring_seconds"): vol.Range(min=1, max=300),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
@@ -65,6 +74,46 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None:
|
||||
) from ex
|
||||
|
||||
|
||||
async def _async_dial(service_call: ServiceCall) -> None:
|
||||
"""Call Fritz dial service."""
|
||||
target_entry_ids = await async_extract_config_entry_ids(service_call)
|
||||
target_entries: list[FritzConfigEntry] = [
|
||||
loaded_entry
|
||||
for loaded_entry in service_call.hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
if loaded_entry.entry_id in target_entry_ids
|
||||
]
|
||||
|
||||
if not target_entries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={"service": service_call.service},
|
||||
)
|
||||
|
||||
for target_entry in target_entries:
|
||||
_LOGGER.debug("Executing service %s", service_call.service)
|
||||
avm_wrapper = target_entry.runtime_data
|
||||
try:
|
||||
await avm_wrapper.async_trigger_dial(
|
||||
service_call.data["number"],
|
||||
max_ring_seconds=service_call.data["max_ring_seconds"],
|
||||
)
|
||||
except (FritzServiceError, FritzActionError) as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_parameter_unknown"
|
||||
) from ex
|
||||
except FritzActionFailedError as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_dial_failed"
|
||||
) from ex
|
||||
except FritzConnectionException as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="service_not_supported"
|
||||
) from ex
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Fritz integration."""
|
||||
@@ -75,3 +124,4 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
_async_set_guest_wifi_password,
|
||||
SERVICE_SCHEMA_SET_GUEST_WIFI_PW,
|
||||
)
|
||||
hass.services.async_register(DOMAIN, SERVICE_DIAL, _async_dial, SERVICE_SCHEMA_DIAL)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user