mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 19:53:18 +02:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 287dea745b | |||
| 3a9f32de25 | |||
| b5e54583c7 | |||
| 85ea7c1176 | |||
| 713f520bc8 | |||
| e4bb5a9395 | |||
| 936b2fe933 | |||
| c6c6f08885 | |||
| c621721851 | |||
| 5bb6b20641 | |||
| 37f41d8e09 | |||
| b02f312bed | |||
| 3520c821c5 | |||
| cbf737a03e | |||
| 5bd6d52e6a | |||
| d9a89beb3d | |||
| 41f783f14d | |||
| 35397b818d | |||
| d42d02f20a | |||
| 99c445f261 | |||
| 567fe85828 | |||
| fd1a5d0c5a | |||
| 632ec39d53 | |||
| 67b9d28953 | |||
| e3880eedb0 | |||
| ce64f5f902 | |||
| 0da99a50fc | |||
| 43f636be65 | |||
| 262cdbfab5 | |||
| 8cbd358435 | |||
| df04b19a0a | |||
| adeb352079 | |||
| 1e457600f1 | |||
| 51d1d4aa9e | |||
| 8184b93151 | |||
| 403cb85bc8 | |||
| 4bf3a5b4bd | |||
| 5a73d78c90 | |||
| ebd9934213 | |||
| 73898c29e2 | |||
| 3372bf45ec | |||
| 9744388a4e | |||
| 75c52a382e | |||
| f8a65a7c6f | |||
| b2d934fae1 | |||
| eb72a72182 | |||
| a4b9de867c | |||
| 3a4e697414 | |||
| 00010a7508 | |||
| c5e4e97fa9 | |||
| 3f6e323b48 | |||
| b9639ec9f6 | |||
| 31bce13d16 | |||
| 3523a26abd | |||
| a6fcc9f3ff | |||
| efe0000fbe | |||
| 98a7cc66ef | |||
| 7feaf71b9e | |||
| 00a0fae7bc | |||
| 0c816c22e0 | |||
| 42f277716d | |||
| 6669b0de25 | |||
| 50fca42624 | |||
| deecb4ee9c | |||
| 762f07f450 | |||
| e02ea041b7 | |||
| 7912afb765 | |||
| 7adaa09333 | |||
| c5e7ed9aba | |||
| 68b8667998 | |||
| f643dd98e5 | |||
| dcec29dbbf | |||
| 1daff77591 | |||
| 7e3fc18c8c | |||
| b6cc5499aa | |||
| 11920b82fe |
@@ -43,6 +43,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
@@ -530,7 +530,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -543,7 +543,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.46
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -961,7 +961,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1325,7 +1325,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1383,7 +1383,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@d3abfe96a194bce3a523ed2093ddedd5704cdf62 # v0.74.4
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
|
||||
@@ -39,7 +39,7 @@ on:
|
||||
env:
|
||||
CACHE_VERSION: 3
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.6"
|
||||
HA_SHORT_VERSION: "2026.7"
|
||||
ADDITIONAL_PYTHON_VERSIONS: "[]"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
@@ -853,49 +853,12 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pytest test counts cache
|
||||
id: cache-pytest-counts
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
# Primary key is a sentinel; restore-keys pick the most recent
|
||||
# prefix match since the real (content-addressed) key isn't
|
||||
# known until split_tests.py runs below.
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-restore-sentinel
|
||||
restore-keys: |
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests \
|
||||
--cache pytest_test_counts.json \
|
||||
${TEST_GROUP_COUNT} tests
|
||||
- name: Hash pytest test counts cache
|
||||
id: cache-pytest-counts-hash
|
||||
run: |
|
||||
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
- name: Save pytest test counts cache
|
||||
# Content-addressed key: identical content reuses the same entry.
|
||||
# Skip the save when the restore already matched that hash.
|
||||
if: >-
|
||||
!endsWith(
|
||||
steps.cache-pytest-counts.outputs.cache-matched-key,
|
||||
steps.cache-pytest-counts-hash.outputs.hash
|
||||
)
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-${{
|
||||
steps.cache-pytest-counts-hash.outputs.hash }}
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
issues: write # To lock issues
|
||||
pull-requests: write # To lock pull requests
|
||||
steps:
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
- uses: dessant/lock-threads@851cffe46851ddd2051ea7147ebdc995113241c3 # v6.0.1
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-inactive-days: "30"
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# - No PRs marked as no-stale
|
||||
# - No issues (-1)
|
||||
- name: 60 days stale PRs policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 60
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# - No issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: 90 days stale issues
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
days-before-stale: 90
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
# - No Issues marked as no-stale or help-wanted
|
||||
# - No PRs (-1)
|
||||
- name: Needs more information stale issues policy
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
repo-token: ${{ steps.token.outputs.token }}
|
||||
only-labels: "needs-more-information"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.13
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -33,6 +33,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body. Use `pytest.param` with an `id` parameter to name the test cases clearly.
|
||||
- We use Syrupy for snapshot testing. Leverage `.ambr` snapshots instead of repetitive and exhaustive generation of test data within Python code itself.
|
||||
- Hardcoded `entity_id`s in tests are fine. If the same one is repeated, use a constant.
|
||||
|
||||
## Good practices
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -2054,8 +2054,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/yamaha_musiccast/ @vigonotion @micha91
|
||||
/homeassistant/components/yandex_transport/ @rishatik92 @devbis
|
||||
/tests/components/yandex_transport/ @rishatik92 @devbis
|
||||
/homeassistant/components/yardian/ @h3l1o5
|
||||
/tests/components/yardian/ @h3l1o5
|
||||
/homeassistant/components/yardian/ @aeon-matrix
|
||||
/tests/components/yardian/ @aeon-matrix
|
||||
/homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015
|
||||
/homeassistant/components/yeelightsunflower/ @lindsaymarkward
|
||||
|
||||
@@ -92,8 +92,7 @@ def _extract_backup(
|
||||
):
|
||||
ostf.tar.extractall(
|
||||
path=Path(tempdir, "extracted"),
|
||||
members=securetar.secure_path(ostf.tar),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
backup_meta_file = Path(tempdir, "extracted", "backup.json")
|
||||
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
|
||||
@@ -119,8 +118,7 @@ def _extract_backup(
|
||||
) as istf:
|
||||
istf.extractall(
|
||||
path=Path(tempdir, "homeassistant"),
|
||||
members=securetar.secure_path(istf),
|
||||
filter="fully_trusted",
|
||||
filter="tar",
|
||||
)
|
||||
if restore_content.restore_homeassistant:
|
||||
keep = list(KEEP_BACKUPS)
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.components.select import (
|
||||
PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA,
|
||||
SelectEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_NAME, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -19,9 +19,6 @@ from .hub import AdsHub
|
||||
|
||||
DEFAULT_NAME = "ADS select"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_ADS_VAR): cv.string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.8.0"]
|
||||
"requirements": ["aioamazondevices==13.8.1"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
"requirements": ["yalexs==9.2.1", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: S3ConfigEntry) -> bool:
|
||||
translation_key="invalid_bucket_name",
|
||||
) from err
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_endpoint_url",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cannot_connect": "[%key:component::aws_s3::exceptions::cannot_connect::message%]",
|
||||
"invalid_bucket_name": "[%key:component::aws_s3::exceptions::invalid_bucket_name::message%]",
|
||||
"invalid_credentials": "[%key:component::aws_s3::exceptions::invalid_credentials::message%]",
|
||||
"invalid_endpoint_url": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
"invalid_endpoint_url": "[%key:component::aws_s3::exceptions::invalid_endpoint_url::message%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -48,6 +48,9 @@
|
||||
},
|
||||
"invalid_credentials": {
|
||||
"message": "Bucket cannot be accessed using provided combination of access key ID and secret access key."
|
||||
},
|
||||
"invalid_endpoint_url": {
|
||||
"message": "Invalid endpoint URL. Please make sure it's a valid AWS S3 endpoint URL."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .models import AgentBackup, BackupNotFound, InvalidBackupFilename
|
||||
from .util import read_backup, suggested_filename
|
||||
|
||||
|
||||
@@ -54,7 +54,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
|
||||
@@ -122,7 +128,14 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / suggested_filename(backup)
|
||||
candidate = self._backup_dir / suggested_filename(backup)
|
||||
# suggested_filename does not strip separators; refuse paths that would
|
||||
# land outside the backup directory.
|
||||
if candidate.parent != self._backup_dir:
|
||||
raise InvalidBackupFilename(
|
||||
f"Refusing to write outside {self._backup_dir}: {candidate}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
|
||||
@@ -1978,7 +1978,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
try:
|
||||
backup = await async_add_executor_job(read_backup, temp_file)
|
||||
except (OSError, tarfile.TarError, json.JSONDecodeError, KeyError) as err:
|
||||
except (
|
||||
OSError,
|
||||
tarfile.TarError,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
InvalidBackupFilename,
|
||||
) as err:
|
||||
LOGGER.warning("Unable to parse backup %s: %s", temp_file, err)
|
||||
raise
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import Path, PurePath, PureWindowsPath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
@@ -34,7 +34,7 @@ from homeassistant.util.async_iterator import (
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .const import BUF_SIZE, LOGGER, SECURETAR_CREATE_VERSION
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
from .models import AddonInfo, AgentBackup, Folder, InvalidBackupFilename
|
||||
|
||||
|
||||
class DecryptError(HomeAssistantError):
|
||||
@@ -109,6 +109,13 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
|
||||
date = extra_metadata.get("supervisor.backup_request_date", data["date"])
|
||||
|
||||
name = cast(str, data["name"])
|
||||
# The name is used to derive the on-disk filename via suggested_filename;
|
||||
# reject anything that could escape the backup directory.
|
||||
safe_name = PureWindowsPath(name).name
|
||||
if safe_name != name or name in ("", ".", ".."):
|
||||
raise InvalidBackupFilename(f"Invalid backup name: {name!r}")
|
||||
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=cast(str, data["slug"]),
|
||||
@@ -118,7 +125,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
folders=folders,
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=homeassistant_version,
|
||||
name=cast(str, data["name"]),
|
||||
name=name,
|
||||
protected=cast(bool, data.get("protected", False)),
|
||||
size=backup_path.stat().st_size,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.3.0",
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.14",
|
||||
"habluetooth==6.7.4"
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.7.9"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityNumericalStateChangedTriggerBase,
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerBase,
|
||||
@@ -26,7 +26,7 @@ from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_HVAC_MODE): vol.All(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -175,7 +175,6 @@ class ConfigManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.Any(str, list),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
vol.Optional("entry_id"): cv.string,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
@@ -302,7 +301,6 @@ class SubentryManagerFlowIndexView(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
|
||||
vol.Optional("show_advanced_options", default=False): cv.boolean,
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -22,6 +22,7 @@ from .const import ( # noqa: F401
|
||||
ATTR_LOCATION_NAME,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONF_CONSIDER_HOME,
|
||||
CONF_NEW_DEVICE_DEFAULTS,
|
||||
CONF_SCAN_INTERVAL,
|
||||
|
||||
@@ -36,6 +36,8 @@ DEFAULT_CONSIDER_HOME: Final = timedelta(seconds=180)
|
||||
|
||||
CONF_NEW_DEVICE_DEFAULTS: Final = "new_device_defaults"
|
||||
|
||||
CONF_ASSOCIATED_ZONE: Final = "associated_zone"
|
||||
|
||||
ATTR_ATTRIBUTES: Final = "attributes"
|
||||
ATTR_BATTERY: Final = "battery"
|
||||
ATTR_DEV_ID: Final = "dev_id"
|
||||
|
||||
@@ -12,13 +12,19 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_EVENT,
|
||||
CONF_OPTIONS,
|
||||
CONF_PLATFORM,
|
||||
CONF_TYPE,
|
||||
CONF_ZONE,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.trigger import (
|
||||
TriggerActionType,
|
||||
TriggerInfo,
|
||||
# protected, but only used for legacy triggers
|
||||
_async_attach_trigger_cls,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -79,16 +85,18 @@ async def async_attach_trigger(
|
||||
event = zone.EVENT_ENTER
|
||||
else:
|
||||
event = zone.EVENT_LEAVE
|
||||
|
||||
zone_config = {
|
||||
CONF_PLATFORM: ZONE_DOMAIN,
|
||||
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
zone_config = await zone.async_validate_trigger_config(hass, zone_config)
|
||||
return await zone.async_attach_trigger(
|
||||
hass, zone_config, action, trigger_info, platform_type="device"
|
||||
zone_config = await zone.LegacyZoneTrigger.async_validate_config(
|
||||
hass,
|
||||
{
|
||||
CONF_OPTIONS: {
|
||||
CONF_ENTITY_ID: [config[CONF_ENTITY_ID]],
|
||||
CONF_ZONE: config[CONF_ZONE],
|
||||
CONF_EVENT: event,
|
||||
}
|
||||
},
|
||||
)
|
||||
return await _async_attach_trigger_cls(
|
||||
hass, zone.LegacyZoneTrigger, "device", zone_config, action, trigger_info
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Provide functionality to keep track of devices."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, final
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -16,8 +17,20 @@ from homeassistant.const import (
|
||||
STATE_NOT_HOME,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, State, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HomeAssistant,
|
||||
State,
|
||||
async_get_hass_or_none,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import (
|
||||
DeviceInfo,
|
||||
EventDeviceRegistryUpdatedData,
|
||||
@@ -25,6 +38,8 @@ from homeassistant.helpers.device_registry import (
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.loader import async_suggest_report_issue
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -33,12 +48,15 @@ from .const import (
|
||||
ATTR_IP,
|
||||
ATTR_MAC,
|
||||
ATTR_SOURCE_TYPE,
|
||||
CONF_ASSOCIATED_ZONE,
|
||||
CONNECTED_DEVICE_REGISTERED,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac")
|
||||
|
||||
|
||||
@@ -151,11 +169,35 @@ class BaseTrackerEntity(Entity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_source_type: SourceType
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "battery_level" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated battery_level property on "
|
||||
"a subclass of BaseTrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the device.
|
||||
|
||||
Percentage from 0-100.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
return None
|
||||
|
||||
@@ -199,13 +241,38 @@ class TrackerEntity(
|
||||
_attr_in_zones: list[str] | None = None
|
||||
_attr_latitude: float | None = None
|
||||
_attr_location_accuracy: float = 0
|
||||
# _attr_location_name is deprecated and will be removed in Home Assistant 2027.7
|
||||
_attr_location_name: str | None = None
|
||||
_attr_longitude: float | None = None
|
||||
_attr_source_type: SourceType = SourceType.GPS
|
||||
|
||||
__active_zone: State | None = None
|
||||
# If we reported setting deprecated _attr_location_name
|
||||
__deprecated_attr_location_name_reported = False
|
||||
__in_zones: list[str] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if "location_name" in cls.__dict__:
|
||||
if cls.__module__.startswith("homeassistant.components."):
|
||||
# Don't ask users to report issue for built in integrations,
|
||||
# they already have issues opened on them.
|
||||
return
|
||||
report_issue = async_suggest_report_issue(
|
||||
async_get_hass_or_none(), module=cls.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is overriding the deprecated location_name property on "
|
||||
"an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
cls.__module__,
|
||||
cls.__name__,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling for entities that have location pushed."""
|
||||
@@ -221,8 +288,8 @@ class TrackerEntity(
|
||||
"""Return the entity_id of zones the device is currently in.
|
||||
|
||||
The list may be in any order; the base class sorts it by zone radius
|
||||
and discards zones which do not exist. Ignored if latitude and
|
||||
longitude are both set.
|
||||
and discards zones which do not exist. Takes precedence over latitude
|
||||
and longitude when set (including when set to an empty list).
|
||||
"""
|
||||
return self._attr_in_zones
|
||||
|
||||
@@ -236,7 +303,32 @@ class TrackerEntity(
|
||||
|
||||
@cached_property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
"""Return a location name for the current location of the device.
|
||||
|
||||
The property is deprecated and will be removed in Home Assistant 2027.7.
|
||||
"""
|
||||
if (location_name := self._attr_location_name) is not None:
|
||||
if (
|
||||
not self.__deprecated_attr_location_name_reported
|
||||
and not self.__class__.__module__.startswith(
|
||||
"homeassistant.components."
|
||||
)
|
||||
):
|
||||
report_issue = async_suggest_report_issue(
|
||||
self.hass, module=self.__class__.__module__
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"%s::%s is setting the deprecated _attr_location_name attribute "
|
||||
"on an instance of TrackerEntity, this will be unsupported from "
|
||||
"Home Assistant 2027.7, please %s"
|
||||
),
|
||||
self.__class__.__module__,
|
||||
self.__class__.__name__,
|
||||
report_issue,
|
||||
)
|
||||
self.__deprecated_attr_location_name_reported = True
|
||||
return location_name
|
||||
return self._attr_location_name
|
||||
|
||||
@cached_property
|
||||
@@ -252,11 +344,7 @@ class TrackerEntity(
|
||||
@callback
|
||||
def _async_write_ha_state(self) -> None:
|
||||
"""Calculate active zones."""
|
||||
if self.available and self.latitude is not None and self.longitude is not None:
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
elif (zones := self.in_zones) is not None:
|
||||
if (zones := self.in_zones) is not None:
|
||||
zone_states = sorted(
|
||||
(
|
||||
zone_state
|
||||
@@ -270,6 +358,12 @@ class TrackerEntity(
|
||||
None,
|
||||
)
|
||||
self.__in_zones = [z.entity_id for z in zone_states]
|
||||
elif (
|
||||
self.available and self.latitude is not None and self.longitude is not None
|
||||
):
|
||||
self.__active_zone, self.__in_zones = zone.async_in_zones(
|
||||
self.hass, self.latitude, self.longitude, self.location_accuracy
|
||||
)
|
||||
else:
|
||||
self.__active_zone = None
|
||||
self.__in_zones = None
|
||||
@@ -317,14 +411,120 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
addresses being used to identify the device.
|
||||
"""
|
||||
|
||||
_scanner_option_associated_zone: str = zone.ENTITY_ID_HOME
|
||||
_scanner_option_associated_zone_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the scanner entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
if not self.registry_entry:
|
||||
return
|
||||
self._async_read_entity_options()
|
||||
|
||||
async def async_internal_will_remove_from_hass(self) -> None:
|
||||
"""Call when the scanner entity is about to be removed from hass."""
|
||||
await super().async_internal_will_remove_from_hass()
|
||||
if not self.registry_entry:
|
||||
return
|
||||
if self._scanner_option_associated_zone_unsub is not None:
|
||||
self._scanner_option_associated_zone_unsub()
|
||||
self._scanner_option_associated_zone_unsub = None
|
||||
self._async_clear_associated_zone_issue()
|
||||
|
||||
@callback
|
||||
def async_registry_entry_updated(self) -> None:
|
||||
"""Run when the entity registry entry has been updated."""
|
||||
self._async_read_entity_options()
|
||||
|
||||
@callback
|
||||
def _async_read_entity_options(self) -> None:
|
||||
"""Read entity options from the entity registry.
|
||||
|
||||
Called when the entity registry entry has been updated and before the
|
||||
scanner entity is added to the state machine.
|
||||
"""
|
||||
assert self.registry_entry
|
||||
if (scanner_options := self.registry_entry.options.get(DOMAIN)) and (
|
||||
associated_zone := scanner_options.get(CONF_ASSOCIATED_ZONE)
|
||||
):
|
||||
new_zone = associated_zone
|
||||
else:
|
||||
new_zone = zone.ENTITY_ID_HOME
|
||||
|
||||
if new_zone == self._scanner_option_associated_zone:
|
||||
return
|
||||
|
||||
# Tear down tracking for the previous zone.
|
||||
if self._scanner_option_associated_zone_unsub is not None:
|
||||
self._scanner_option_associated_zone_unsub()
|
||||
self._scanner_option_associated_zone_unsub = None
|
||||
self._async_clear_associated_zone_issue()
|
||||
|
||||
self._scanner_option_associated_zone = new_zone
|
||||
|
||||
# zone.home is always present so no tracking or issue handling needed.
|
||||
if new_zone == zone.ENTITY_ID_HOME:
|
||||
return
|
||||
|
||||
self._scanner_option_associated_zone_unsub = async_track_state_change_event(
|
||||
self.hass, new_zone, self._async_associated_zone_state_changed
|
||||
)
|
||||
if self.hass.states.get(new_zone) is None:
|
||||
self._async_create_associated_zone_issue()
|
||||
|
||||
@callback
|
||||
def _async_associated_zone_state_changed(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Open or clear the repair issue when the associated zone appears or disappears."""
|
||||
if event.data["new_state"] is None:
|
||||
self._async_create_associated_zone_issue()
|
||||
else:
|
||||
self._async_clear_associated_zone_issue()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_create_associated_zone_issue(self) -> None:
|
||||
"""Create a repair issue prompting the user to reconfigure the scanner."""
|
||||
ir.async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
self._associated_zone_issue_id,
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="associated_zone_missing",
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"zone": self._scanner_option_associated_zone,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_clear_associated_zone_issue(self) -> None:
|
||||
"""Clear the associated-zone-missing repair issue if it exists."""
|
||||
ir.async_delete_issue(self.hass, DOMAIN, self._associated_zone_issue_id)
|
||||
|
||||
@property
|
||||
def _associated_zone_issue_id(self) -> str:
|
||||
"""Return the issue id for the associated-zone-missing repair."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.registry_entry
|
||||
return f"associated_zone_missing_{self.registry_entry.id}"
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
"""Return the state of the device."""
|
||||
if self.is_connected is None:
|
||||
return None
|
||||
if self.is_connected:
|
||||
if not self.is_connected:
|
||||
return STATE_NOT_HOME
|
||||
associated_zone = self._scanner_option_associated_zone
|
||||
if associated_zone == zone.ENTITY_ID_HOME:
|
||||
return STATE_HOME
|
||||
return STATE_NOT_HOME
|
||||
if zone_state := self.hass.states.get(associated_zone):
|
||||
return zone_state.name
|
||||
# Configured zone has been removed; state is unknown.
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool | None:
|
||||
@@ -341,9 +541,18 @@ class BaseScannerEntity(BaseTrackerEntity):
|
||||
if not self.is_connected:
|
||||
return attr
|
||||
|
||||
associated_zone = self._scanner_option_associated_zone
|
||||
# If the configured zone has been removed, in_zones stays empty so the
|
||||
# attribute does not claim membership in a zone that no longer exists.
|
||||
if (
|
||||
associated_zone != zone.ENTITY_ID_HOME
|
||||
and self.hass.states.get(associated_zone) is None
|
||||
):
|
||||
return attr
|
||||
|
||||
attr[ATTR_IN_ZONES] = [
|
||||
zone.ENTITY_ID_HOME,
|
||||
*zone.async_get_enclosing_zones(self.hass, zone.ENTITY_ID_HOME),
|
||||
associated_zone,
|
||||
*zone.async_get_enclosing_zones(self.hass, associated_zone),
|
||||
]
|
||||
|
||||
return attr
|
||||
|
||||
@@ -38,6 +38,9 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
async_create_platform_config_not_supported_issue,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
async_track_utc_time_change,
|
||||
@@ -379,8 +382,8 @@ async def async_extract_config(
|
||||
if platform.type == PLATFORM_TYPE_LEGACY:
|
||||
legacy.append(platform)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unable to determine type for {platform.name}: {platform.type}"
|
||||
async_create_platform_config_not_supported_issue(
|
||||
hass, platform.name, DOMAIN
|
||||
)
|
||||
|
||||
return legacy
|
||||
|
||||
@@ -44,6 +44,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"associated_zone_missing": {
|
||||
"description": "The scanner entity `{entity_id}` is associated with the zone `{zone}`, but that zone has been removed.\n\nTo fix this, reconfigure the scanner to use a different zone or recreate the missing zone.",
|
||||
"title": "Scanner is associated with a removed zone"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"see": {
|
||||
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -50,7 +50,7 @@ class DuckDnsUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Update Duck DNS."""
|
||||
|
||||
retry_after = BACKOFF_INTERVALS[
|
||||
min(self.failed, len(BACKOFF_INTERVALS))
|
||||
min(self.failed, len(BACKOFF_INTERVALS) - 1)
|
||||
].total_seconds()
|
||||
|
||||
try:
|
||||
|
||||
@@ -64,23 +64,23 @@
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
"state": {
|
||||
"aut1": "Automatic boost (15 min)",
|
||||
"aut2": "Automatic boost (30 min)",
|
||||
"aut3": "Automatic boost (45 min)",
|
||||
"auto": "Automatic",
|
||||
"cnt1": "Continuous low speed",
|
||||
"cnt2": "Continuous medium speed",
|
||||
"cnt3": "Continuous high speed",
|
||||
"empt": "Empty house",
|
||||
"man1": "Manual low speed (15 min)",
|
||||
"man1x2": "Manual low speed (30 min)",
|
||||
"man1x3": "Manual low speed (45 min)",
|
||||
"man2": "Manual medium speed (15 min)",
|
||||
"man2x2": "Manual medium speed (30 min)",
|
||||
"man2x3": "Manual medium speed (45 min)",
|
||||
"man3": "Manual high speed (15 min)",
|
||||
"man3x2": "Manual high speed (30 min)",
|
||||
"man3x3": "Manual high speed (45 min)"
|
||||
"aut1": "AUT1",
|
||||
"aut2": "AUT2",
|
||||
"aut3": "AUT3",
|
||||
"auto": "AUTO",
|
||||
"cnt1": "CNT1",
|
||||
"cnt2": "CNT2",
|
||||
"cnt3": "CNT3",
|
||||
"empt": "EMPT",
|
||||
"man1": "MAN1",
|
||||
"man1x2": "MAN1x2",
|
||||
"man1x3": "MAN1x3",
|
||||
"man2": "MAN2",
|
||||
"man2x2": "MAN2x2",
|
||||
"man2x3": "MAN2x3",
|
||||
"man3": "MAN3",
|
||||
"man3x2": "MAN3x2",
|
||||
"man3x3": "MAN3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather
|
||||
|
||||
from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ECConfigEntry) ->
|
||||
errors = errors + 1
|
||||
_LOGGER.warning("Unable to retrieve Environment Canada weather")
|
||||
|
||||
radar_data = ECRadar(coordinates=(lat, lon))
|
||||
radar_data = ECMap(coordinates=(lat, lon), layer="precip_type", legend=False)
|
||||
radar_coordinator = ECDataUpdateCoordinator(
|
||||
hass, config_entry, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for the Environment Canada radar imagery."""
|
||||
|
||||
from env_canada import ECRadar
|
||||
from env_canada import ECMap
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
@@ -11,13 +11,20 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import ATTR_OBSERVATION_TIME
|
||||
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
|
||||
|
||||
SERVICE_SET_RADAR_TYPE = "set_radar_type"
|
||||
SET_RADAR_TYPE_SCHEMA: VolDictType = {
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow"]),
|
||||
vol.Required("radar_type"): vol.In(["Auto", "Rain", "Snow", "Precipitation type"]),
|
||||
}
|
||||
|
||||
_RADAR_TYPE_TO_LAYER: dict[str, str] = {
|
||||
"Rain": "rain",
|
||||
"Snow": "snow",
|
||||
"Precipitation type": "precip_type",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,13 +45,13 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera):
|
||||
class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECMap]], Camera):
|
||||
"""Implementation of an Environment Canada radar camera."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "radar"
|
||||
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECRadar]) -> None:
|
||||
def __init__(self, coordinator: ECDataUpdateCoordinator[ECMap]) -> None:
|
||||
"""Initialize the camera."""
|
||||
super().__init__(coordinator)
|
||||
Camera.__init__(self)
|
||||
@@ -76,6 +83,13 @@ class ECCameraEntity(CoordinatorEntity[ECDataUpdateCoordinator[ECRadar]], Camera
|
||||
|
||||
async def async_set_radar_type(self, radar_type: str) -> None:
|
||||
"""Set the type of radar to retrieve."""
|
||||
if radar_type == "Auto":
|
||||
# Choose rain for months April through October, snow otherwise
|
||||
layer = "rain" if dt_util.now().month in range(4, 11) else "snow"
|
||||
else:
|
||||
layer = _RADAR_TYPE_TO_LAYER[radar_type]
|
||||
|
||||
# Apply new layer and clear cache to force refresh
|
||||
self.radar_object.layer = layer
|
||||
self.radar_object.clear_cache()
|
||||
self.radar_object.precip_type = radar_type.lower()
|
||||
await self.radar_object.update()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
from env_canada import ECAirQuality, ECMap, ECWeather, ECWeatherUpdateFailed, ec_exc
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -17,7 +17,7 @@ from .const import DOMAIN
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type ECConfigEntry = ConfigEntry[ECRuntimeData]
|
||||
type ECDataType = ECAirQuality | ECRadar | ECWeather
|
||||
type ECDataType = ECAirQuality | ECMap | ECWeather
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +25,7 @@ class ECRuntimeData:
|
||||
"""Class to hold EC runtime data."""
|
||||
|
||||
aqhi_coordinator: ECDataUpdateCoordinator[ECAirQuality]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECRadar]
|
||||
radar_coordinator: ECDataUpdateCoordinator[ECMap]
|
||||
weather_coordinator: ECDataUpdateCoordinator[ECWeather]
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ set_radar_type:
|
||||
fields:
|
||||
radar_type:
|
||||
required: true
|
||||
example: Snow
|
||||
example: Rain
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "Auto"
|
||||
- "Rain"
|
||||
- "Snow"
|
||||
- "Precipitation type"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260429.4"]
|
||||
"requirements": ["home-assistant-frontend==20260527.0"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -19,7 +19,7 @@ DEFAULT_STT_PROMPT = "Transcribe the attached audio"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-3.1-flash-lite"
|
||||
RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_IMAGE_MODEL = "models/gemini-2.5-flash-image"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Home Assistant Hardware",
|
||||
"after_dependencies": ["hassio"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["usb"],
|
||||
"dependencies": ["repairs", "usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Repairs for the Home Assistant Hardware integration."""
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
ISSUE_MULTI_PAN_MIGRATION = "multi_pan_migration"
|
||||
|
||||
|
||||
@callback
|
||||
def _multi_pan_issue_id(config_entry: ConfigEntry) -> str:
|
||||
"""Return the issue id for the multi-PAN migration issue of an entry."""
|
||||
return f"{ISSUE_MULTI_PAN_MIGRATION}_{config_entry.entry_id}"
|
||||
|
||||
|
||||
@callback
|
||||
def async_create_multi_pan_migration_issue(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Create a repair issue to guide migration away from Multi-PAN."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
domain=domain,
|
||||
issue_id=_multi_pan_issue_id(config_entry),
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_MULTI_PAN_MIGRATION,
|
||||
translation_placeholders={"hardware_name": config_entry.title},
|
||||
data={"entry_id": config_entry.entry_id},
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_delete_multi_pan_migration_issue(
|
||||
hass: HomeAssistant,
|
||||
domain: str,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Delete the multi-PAN migration repair issue for this entry."""
|
||||
ir.async_delete_issue(hass, domain, _multi_pan_issue_id(config_entry))
|
||||
|
||||
|
||||
class MultiPanMigrationRepairFlow(RepairsFlow):
|
||||
"""Reuse the multi-PAN options flow uninstall steps as a repair flow.
|
||||
|
||||
Subclass this together with the hardware-specific
|
||||
``MultiPanOptionsFlowHandler`` in each hardware integration's repairs
|
||||
module.
|
||||
|
||||
The repair flow runs in the repairs flow manager where ``self.handler``
|
||||
is the integration domain rather than the hardware config entry id, so
|
||||
the ``config_entry`` accessor of ``OptionsFlow`` must be overridden.
|
||||
"""
|
||||
|
||||
_repair_config_entry: ConfigEntry
|
||||
|
||||
@property
|
||||
def config_entry(self) -> ConfigEntry:
|
||||
"""Return the hardware config entry to migrate."""
|
||||
return self._repair_config_entry
|
||||
|
||||
async def _async_step_start_migration(self) -> RepairsFlowResult:
|
||||
"""Jump straight into the uninstall step of the migration flow.
|
||||
|
||||
The repair flow's init data is the issue context, not user form input,
|
||||
so pass None to render the uninstall confirmation form.
|
||||
"""
|
||||
return await self.async_step_uninstall_addon() # type: ignore[attr-defined, no-any-return]
|
||||
@@ -6,6 +6,8 @@ import dataclasses
|
||||
import logging
|
||||
from typing import Any, Protocol
|
||||
|
||||
from aiohttp import ClientError
|
||||
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
@@ -25,6 +27,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.integration_platform import (
|
||||
async_process_integration_platforms,
|
||||
@@ -37,15 +40,18 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import LOGGER, SILABS_FLASHER_ADDON_SLUG, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
||||
from .const import DOMAIN, LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
WaitingAddonManager,
|
||||
async_firmware_flashing_context,
|
||||
async_flash_silabs_firmware,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_MULTIPROTOCOL_ADDON_MANAGER = "silabs_multiprotocol_addon_manager"
|
||||
DATA_FLASHER_ADDON_MANAGER = "silabs_flasher"
|
||||
|
||||
ADDON_STATE_POLL_INTERVAL = 3
|
||||
ADDON_INFO_POLL_TIMEOUT = 15 * 60
|
||||
|
||||
CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware"
|
||||
CONF_ADDON_DEVICE = "device"
|
||||
@@ -71,53 +77,6 @@ async def get_multiprotocol_addon_manager(
|
||||
return manager
|
||||
|
||||
|
||||
class WaitingAddonManager(AddonManager):
|
||||
"""Addon manager which supports waiting operations for managing an addon."""
|
||||
|
||||
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
|
||||
"""Poll an addon's info until it is in a specific state."""
|
||||
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
|
||||
while True:
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
|
||||
|
||||
if info is not None and info.state in states:
|
||||
break
|
||||
|
||||
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
|
||||
|
||||
async def async_start_addon_waiting(self) -> None:
|
||||
"""Start an add-on."""
|
||||
await self.async_schedule_start_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.RUNNING)
|
||||
|
||||
async def async_install_addon_waiting(self) -> None:
|
||||
"""Install an add-on."""
|
||||
await self.async_schedule_install_addon()
|
||||
await self.async_wait_until_addon_state(
|
||||
AddonState.RUNNING,
|
||||
AddonState.NOT_RUNNING,
|
||||
)
|
||||
|
||||
async def async_uninstall_addon_waiting(self) -> None:
|
||||
"""Uninstall an add-on."""
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
# Do not try to uninstall an addon if it is already uninstalled
|
||||
if info is not None and info.state is AddonState.NOT_INSTALLED:
|
||||
return
|
||||
|
||||
await self.async_uninstall_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
|
||||
|
||||
|
||||
class MultiprotocolAddonManager(WaitingAddonManager):
|
||||
"""Silicon Labs Multiprotocol add-on manager."""
|
||||
|
||||
@@ -265,18 +224,6 @@ class MultipanProtocol(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
@singleton(DATA_FLASHER_ADDON_MANAGER)
|
||||
@callback
|
||||
def get_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
|
||||
"""Get the flasher add-on manager."""
|
||||
return WaitingAddonManager(
|
||||
hass,
|
||||
LOGGER,
|
||||
"Silicon Labs Flasher",
|
||||
SILABS_FLASHER_ADDON_SLUG,
|
||||
)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class SerialPortSettings:
|
||||
"""Serial port settings."""
|
||||
@@ -339,6 +286,19 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
def _zha_name(self) -> str:
|
||||
"""Return the ZHA name."""
|
||||
|
||||
@abstractmethod
|
||||
def _firmware_update_url(self) -> str:
|
||||
"""Return the firmware update manifest URL."""
|
||||
|
||||
@abstractmethod
|
||||
def _zigbee_firmware_type(self) -> str:
|
||||
"""Return the zigbee firmware type identifier (e.g. 'yellow_zigbee_ncp')."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _flasher_cls(self) -> type:
|
||||
"""Return the hardware-specific flasher class."""
|
||||
|
||||
@property
|
||||
def flow_manager(self) -> OptionsFlowManager:
|
||||
"""Return the correct flow manager."""
|
||||
@@ -686,61 +646,7 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
async def async_step_firmware_revert(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install the flasher addon, if necessary."""
|
||||
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_flasher_addon()
|
||||
|
||||
if addon_info.state is AddonState.NOT_RUNNING:
|
||||
return await self.async_step_configure_flasher_addon()
|
||||
|
||||
# If the addon is already installed and running, fail
|
||||
return self.async_abort(
|
||||
reason="addon_already_running",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
)
|
||||
|
||||
async def async_step_install_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing flasher addon."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
|
||||
_LOGGER.debug("Flasher addon state: %s", addon_info)
|
||||
|
||||
if not self.install_task:
|
||||
self.install_task = self.hass.async_create_task(
|
||||
flasher_manager.async_install_addon_waiting(),
|
||||
"SiLabs Flasher addon install",
|
||||
eager_start=False,
|
||||
)
|
||||
|
||||
if not self.install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="install_flasher_addon",
|
||||
progress_action="install_addon",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
progress_task=self.install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.install_task
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="install_failed")
|
||||
finally:
|
||||
self.install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="configure_flasher_addon")
|
||||
|
||||
async def async_step_configure_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform initial backup and reconfigure ZHA."""
|
||||
"""Initiate ZHA backup and start multiprotocol addon uninstall."""
|
||||
# pylint: disable=home-assistant-component-root-import
|
||||
from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415
|
||||
from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415
|
||||
@@ -782,17 +688,6 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
_LOGGER.exception("Unexpected exception during ZHA migration")
|
||||
raise AbortFlow("zha_migration_failed") from err
|
||||
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(flasher_manager)
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": new_settings.device,
|
||||
"flow_control": new_settings.flow_control,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config, flasher_manager)
|
||||
|
||||
return await self.async_step_uninstall_multiprotocol_addon()
|
||||
|
||||
async def async_step_uninstall_multiprotocol_addon(
|
||||
@@ -821,62 +716,93 @@ class OptionsFlowHandler(OptionsFlow, ABC):
|
||||
finally:
|
||||
self.stop_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="start_flasher_addon")
|
||||
return self.async_show_progress_done(next_step_id="install_zigbee_firmware")
|
||||
|
||||
async def async_step_start_flasher_addon(
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Start Silicon Labs Flasher add-on."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
"""Flash Zigbee firmware directly onto the radio."""
|
||||
if not self.install_task:
|
||||
|
||||
if not self.start_task:
|
||||
async def _flash_firmware() -> None:
|
||||
serial_port_settings = await self._async_serial_port_settings()
|
||||
device = serial_port_settings.device
|
||||
|
||||
async def start_and_wait_until_done() -> None:
|
||||
await flasher_manager.async_start_addon_waiting()
|
||||
# Now that the addon is running, wait for it to finish
|
||||
await flasher_manager.async_wait_until_addon_state(
|
||||
AddonState.NOT_RUNNING
|
||||
)
|
||||
# For the duration of firmware flashing, hint to other integrations
|
||||
# (i.e. ZHA) that the hardware is in use and should not be accessed.
|
||||
async with async_firmware_flashing_context(self.hass, device, DOMAIN):
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(self._firmware_update_url(), session)
|
||||
|
||||
self.start_task = self.hass.async_create_task(
|
||||
start_and_wait_until_done(), eager_start=False
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw
|
||||
for fw in manifest.firmwares
|
||||
if fw.filename.startswith(self._zigbee_firmware_type())
|
||||
)
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (
|
||||
StopIteration,
|
||||
TimeoutError,
|
||||
ClientError,
|
||||
ManifestMissing,
|
||||
ValueError,
|
||||
) as err:
|
||||
raise HomeAssistantError(
|
||||
"Failed to fetch Zigbee firmware"
|
||||
) from err
|
||||
|
||||
await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=device,
|
||||
fw_data=fw_data,
|
||||
flasher_cls=self._flasher_cls,
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
progress_callback=lambda offset, total: (
|
||||
self.async_update_progress(offset / total)
|
||||
),
|
||||
)
|
||||
|
||||
self.install_task = self.hass.async_create_task(
|
||||
_flash_firmware(),
|
||||
"Flash Zigbee firmware",
|
||||
eager_start=False,
|
||||
)
|
||||
|
||||
if not self.start_task.done():
|
||||
if not self.install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="start_flasher_addon",
|
||||
progress_action="start_flasher_addon",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
progress_task=self.start_task,
|
||||
step_id="install_zigbee_firmware",
|
||||
progress_action="install_zigbee_firmware",
|
||||
description_placeholders={
|
||||
"hardware_name": self._hardware_name(),
|
||||
},
|
||||
progress_task=self.install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
return self.async_show_progress_done(next_step_id="flasher_failed")
|
||||
await self.install_task
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error("Failed to flash Zigbee firmware: %s", err)
|
||||
return self.async_show_progress_done(next_step_id="firmware_flash_failed")
|
||||
finally:
|
||||
self.start_task = None
|
||||
self.install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="flashing_complete")
|
||||
|
||||
async def async_step_flasher_failed(
|
||||
async def async_step_firmware_flash_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Flasher add-on start failed."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
"""Firmware flashing failed."""
|
||||
return self.async_abort(
|
||||
reason="addon_start_failed",
|
||||
description_placeholders={"addon_name": flasher_manager.addon_name},
|
||||
reason="fw_install_failed",
|
||||
description_placeholders={"firmware_name": "Zigbee"},
|
||||
)
|
||||
|
||||
async def async_step_flashing_complete(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Finish flashing and update the config entry."""
|
||||
flasher_manager = get_flasher_addon_manager(self.hass)
|
||||
await flasher_manager.async_uninstall_addon_waiting()
|
||||
|
||||
# Finish ZHA migration if needed
|
||||
if self._zha_migration_mgr:
|
||||
try:
|
||||
|
||||
@@ -102,7 +102,9 @@
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "Please wait while the {addon_name} app installation finishes. This can take several minutes.",
|
||||
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds."
|
||||
"install_zigbee_firmware": "Please wait while Zigbee-only firmware is installed on your {hardware_name}. This can take several minutes.",
|
||||
"start_addon": "Please wait while the {addon_name} app start completes. This may take some seconds.",
|
||||
"uninstall_multiprotocol_addon": "Please wait while the {addon_name} app is uninstalled."
|
||||
},
|
||||
"step": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -37,13 +37,59 @@ from .const import (
|
||||
ZIGBEE_FLASHER_ADDON_SLUG,
|
||||
)
|
||||
from .helpers import async_firmware_update_context
|
||||
from .silabs_multiprotocol_addon import (
|
||||
WaitingAddonManager,
|
||||
get_multiprotocol_addon_manager,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ADDON_STATE_POLL_INTERVAL = 3
|
||||
ADDON_INFO_POLL_TIMEOUT = 15 * 60
|
||||
|
||||
|
||||
class WaitingAddonManager(AddonManager):
|
||||
"""Addon manager which supports waiting operations for managing an addon."""
|
||||
|
||||
async def async_wait_until_addon_state(self, *states: AddonState) -> None:
|
||||
"""Poll an addon's info until it is in a specific state."""
|
||||
async with asyncio.timeout(ADDON_INFO_POLL_TIMEOUT):
|
||||
while True:
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
_LOGGER.debug("Waiting for addon to be in state %s: %s", states, info)
|
||||
|
||||
if info is not None and info.state in states:
|
||||
break
|
||||
|
||||
await asyncio.sleep(ADDON_STATE_POLL_INTERVAL)
|
||||
|
||||
async def async_start_addon_waiting(self) -> None:
|
||||
"""Start an add-on."""
|
||||
await self.async_schedule_start_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.RUNNING)
|
||||
|
||||
async def async_install_addon_waiting(self) -> None:
|
||||
"""Install an add-on."""
|
||||
await self.async_schedule_install_addon()
|
||||
await self.async_wait_until_addon_state(
|
||||
AddonState.RUNNING,
|
||||
AddonState.NOT_RUNNING,
|
||||
)
|
||||
|
||||
async def async_uninstall_addon_waiting(self) -> None:
|
||||
"""Uninstall an add-on."""
|
||||
try:
|
||||
info = await self.async_get_addon_info()
|
||||
except AddonError:
|
||||
info = None
|
||||
|
||||
# Do not try to uninstall an addon if it is already uninstalled
|
||||
if info is not None and info.state == AddonState.NOT_INSTALLED:
|
||||
return
|
||||
|
||||
await self.async_uninstall_addon()
|
||||
await self.async_wait_until_addon_state(AddonState.NOT_INSTALLED)
|
||||
|
||||
|
||||
class ApplicationType(StrEnum):
|
||||
"""Application type running on a device."""
|
||||
@@ -279,6 +325,11 @@ async def guess_hardware_owners(
|
||||
assert otbr_addon_fw_info is not None
|
||||
device_guesses[otbr_path].append(otbr_addon_fw_info)
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
from .silabs_multiprotocol_addon import ( # noqa: PLC0415
|
||||
get_multiprotocol_addon_manager,
|
||||
)
|
||||
|
||||
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,13 @@ import os.path
|
||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||
FirmwareUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||
async_create_multi_pan_migration_issue,
|
||||
async_delete_multi_pan_migration_issue,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||
multi_pan_addon_using_device,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
|
||||
from homeassistant.components.usb import (
|
||||
USBDevice,
|
||||
@@ -92,6 +99,16 @@ async def async_setup_entry(
|
||||
translation_key="device_disconnected",
|
||||
)
|
||||
|
||||
try:
|
||||
uses_multi_pan = await multi_pan_addon_using_device(hass, device_path)
|
||||
except HomeAssistantError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
if uses_multi_pan:
|
||||
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||
else:
|
||||
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||
|
||||
# Create and store the firmware update coordinator in runtime_data
|
||||
session = async_get_clientsession(hass)
|
||||
coordinator = FirmwareUpdateCoordinator(
|
||||
|
||||
@@ -248,6 +248,19 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
|
||||
"""Return the name of the hardware."""
|
||||
return self._hw_variant.full_name
|
||||
|
||||
def _firmware_update_url(self) -> str:
|
||||
"""Return the firmware update manifest URL."""
|
||||
return NABU_CASA_FIRMWARE_RELEASES_URL
|
||||
|
||||
def _zigbee_firmware_type(self) -> str:
|
||||
"""Return the zigbee firmware type identifier."""
|
||||
return "skyconnect_zigbee_ncp"
|
||||
|
||||
@property
|
||||
def _flasher_cls(self) -> type:
|
||||
"""Return the hardware-specific flasher class."""
|
||||
return Zbt1Flasher # type: ignore[no-any-return]
|
||||
|
||||
async def async_step_flashing_complete(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Repairs for the Home Assistant SkyConnect integration."""
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||
ISSUE_MULTI_PAN_MIGRATION,
|
||||
MultiPanMigrationRepairFlow,
|
||||
)
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .config_flow import HomeAssistantSkyConnectMultiPanOptionsFlowHandler
|
||||
|
||||
|
||||
class SkyConnectMultiPanMigrationRepairFlow(
|
||||
MultiPanMigrationRepairFlow, HomeAssistantSkyConnectMultiPanOptionsFlowHandler
|
||||
):
|
||||
"""Multi-PAN migration repair flow for Home Assistant SkyConnect."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the repair flow."""
|
||||
HomeAssistantSkyConnectMultiPanOptionsFlowHandler.__init__(self, config_entry)
|
||||
self._repair_config_entry = config_entry
|
||||
|
||||
async def async_step_init( # type: ignore[override]
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Jump straight into the uninstall step."""
|
||||
return await self._async_step_start_migration()
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create a fix flow for a SkyConnect repair issue."""
|
||||
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
|
||||
entry_id = cast(str, data["entry_id"])
|
||||
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
|
||||
return SkyConnectMultiPanMigrationRepairFlow(entry)
|
||||
|
||||
return ConfirmRepairFlow()
|
||||
@@ -106,6 +106,37 @@
|
||||
"message": "The device is not plugged in"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"multi_pan_migration": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
|
||||
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||
},
|
||||
"step": {
|
||||
"uninstall_addon": {
|
||||
"data": {
|
||||
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||
},
|
||||
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Multiprotocol support is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
@@ -130,8 +161,10 @@
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
|
||||
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||
},
|
||||
"step": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -7,8 +7,13 @@ from homeassistant.components.hassio import HassioNotReadyError, get_os_info
|
||||
from homeassistant.components.homeassistant_hardware.coordinator import (
|
||||
FirmwareUpdateCoordinator,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||
async_create_multi_pan_migration_issue,
|
||||
async_delete_multi_pan_migration_issue,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||
check_multi_pan_addon,
|
||||
multi_pan_addon_using_device,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
@@ -27,6 +32,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
MANUFACTURER,
|
||||
@@ -77,6 +83,16 @@ async def async_setup_entry(
|
||||
except HomeAssistantError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
try:
|
||||
multipan_using_device = await multi_pan_addon_using_device(hass, RADIO_DEVICE)
|
||||
except HomeAssistantError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
if multipan_using_device:
|
||||
async_create_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||
else:
|
||||
async_delete_multi_pan_migration_issue(hass, DOMAIN, entry)
|
||||
|
||||
if firmware is ApplicationType.EZSP:
|
||||
discovery_flow.async_create_flow(
|
||||
hass,
|
||||
|
||||
@@ -319,6 +319,19 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
|
||||
"""Return the name of the hardware."""
|
||||
return BOARD_NAME
|
||||
|
||||
def _firmware_update_url(self) -> str:
|
||||
"""Return the firmware update manifest URL."""
|
||||
return NABU_CASA_FIRMWARE_RELEASES_URL
|
||||
|
||||
def _zigbee_firmware_type(self) -> str:
|
||||
"""Return the zigbee firmware type identifier."""
|
||||
return "yellow_zigbee_ncp"
|
||||
|
||||
@property
|
||||
def _flasher_cls(self) -> type:
|
||||
"""Return the hardware-specific flasher class."""
|
||||
return YellowFlasher # type: ignore[no-any-return]
|
||||
|
||||
async def async_step_flashing_complete(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Repairs for the Home Assistant Yellow integration."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.homeassistant_hardware.repair_helpers import (
|
||||
ISSUE_MULTI_PAN_MIGRATION,
|
||||
MultiPanMigrationRepairFlow,
|
||||
)
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .config_flow import HomeAssistantYellowMultiPanOptionsFlowHandler
|
||||
|
||||
|
||||
class YellowMultiPanMigrationRepairFlow(
|
||||
MultiPanMigrationRepairFlow, HomeAssistantYellowMultiPanOptionsFlowHandler
|
||||
):
|
||||
"""Multi-PAN migration repair flow for Home Assistant Yellow."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the repair flow."""
|
||||
HomeAssistantYellowMultiPanOptionsFlowHandler.__init__(self, hass, config_entry)
|
||||
self._repair_config_entry = config_entry
|
||||
|
||||
async def async_step_main_menu( # type: ignore[override]
|
||||
self, _: None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Jump straight into the uninstall step."""
|
||||
return await self._async_step_start_migration()
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create a fix flow for a Yellow repair issue."""
|
||||
if issue_id.startswith(ISSUE_MULTI_PAN_MIGRATION) and data is not None:
|
||||
entry_id = cast(str, data["entry_id"])
|
||||
if (entry := hass.config_entries.async_get_entry(entry_id)) is not None:
|
||||
return YellowMultiPanMigrationRepairFlow(hass, entry)
|
||||
|
||||
return ConfirmRepairFlow()
|
||||
@@ -11,6 +11,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"multi_pan_migration": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
"addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]",
|
||||
"addon_install_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_install_failed%]",
|
||||
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]",
|
||||
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||
},
|
||||
"step": {
|
||||
"uninstall_addon": {
|
||||
"data": {
|
||||
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||
},
|
||||
"description": "Multiprotocol support for the IEEE 802.15.4 radio in your {hardware_name} has been deprecated. Migrate the radio back to Zigbee-only firmware to keep it working in the future.\n\nDisabling multiprotocol support will disable Thread support provided by the {hardware_name}. Your Thread devices will continue working only if you have another Thread border router nearby.\n\nIt will take a few minutes to install the Zigbee firmware and restore a backup.",
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Multiprotocol support is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"addon_already_running": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_already_running%]",
|
||||
@@ -37,8 +68,10 @@
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]",
|
||||
"install_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_otbr_addon%]",
|
||||
"install_zigbee_firmware": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_zigbee_firmware%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]"
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::start_otbr_addon%]",
|
||||
"uninstall_multiprotocol_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::uninstall_multiprotocol_addon%]"
|
||||
},
|
||||
"step": {
|
||||
"addon_installed_other_device": {
|
||||
|
||||
@@ -178,17 +178,21 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_max := color_temp.mirek_schema.mirek_maximum
|
||||
):
|
||||
return mirek_max
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
if (color_temp := self.resource.color_temperature) and (
|
||||
mirek_min := color_temp.mirek_schema.mirek_minimum
|
||||
):
|
||||
return mirek_min
|
||||
# return a fallback value if the light doesn't provide valid limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.trigger import (
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction, HumidifierEntityFeature
|
||||
|
||||
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
|
||||
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_WITH_BEHAVIOR.extend(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -15,7 +15,6 @@ CONF_INFO = "info"
|
||||
CONF_INVERTING = "inverting"
|
||||
CONF_LIGHT = "light"
|
||||
CONF_NODE = "node"
|
||||
CONF_NOTE = "note"
|
||||
CONF_OFF_ID = "off_id"
|
||||
CONF_ON_ID = "on_id"
|
||||
CONF_POSITION = "position"
|
||||
|
||||
@@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_NOTE,
|
||||
CONF_PASSWORD,
|
||||
CONF_TYPE,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
@@ -25,7 +26,6 @@ from .const import (
|
||||
CONF_INFO,
|
||||
CONF_INVERTING,
|
||||
CONF_LIGHT,
|
||||
CONF_NOTE,
|
||||
CONF_OFF_ID,
|
||||
CONF_ON_ID,
|
||||
CONF_POSITION,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -97,11 +97,13 @@ class IncomfortBinarySensor(IncomfortBoilerEntity, BinarySensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def is_on(self) -> bool:
|
||||
"""Return the status of the sensor."""
|
||||
return bool(self._heater.status[self.entity_description.value_key])
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
if (attributes_fn := self.entity_description.extra_state_attributes_fn) is None:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom
|
||||
|
||||
@@ -76,16 +76,19 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {"status": self._room.status}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._room.room_temp
|
||||
|
||||
@property
|
||||
@override
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the actual current HVAC action."""
|
||||
if self._heater.is_burning and self._heater.is_pumping:
|
||||
@@ -93,6 +96,7 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return HVACAction.IDLE
|
||||
|
||||
@property
|
||||
@override
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the (override)temperature we try to reach.
|
||||
|
||||
@@ -106,11 +110,13 @@ class InComfortClimate(IncomfortEntity, ClimateEntity):
|
||||
return self._room.setpoint
|
||||
return self._room.override or self._room.setpoint
|
||||
|
||||
@override
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set a new target temperature for this zone."""
|
||||
temperature: float = kwargs[ATTR_TEMPERATURE]
|
||||
await self._room.set_override(temperature)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@override
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import InvalidGateway, InvalidHeaterList
|
||||
import voluptuous as vol
|
||||
@@ -100,6 +100,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
_discovered_host: str
|
||||
|
||||
@override
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
@@ -108,6 +109,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return InComfortOptionsFlowHandler()
|
||||
|
||||
@override
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
@@ -169,6 +171,7 @@ class InComfortConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_HOST: self._discovered_host},
|
||||
)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from incomfortclient import (
|
||||
@@ -74,6 +74,7 @@ class InComfortDataCoordinator(DataUpdateCoordinator[InComfortData]):
|
||||
)
|
||||
self.incomfort_data = incomfort_data
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> InComfortData:
|
||||
"""Fetch data from API endpoint."""
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -104,11 +104,13 @@ class IncomfortSensor(IncomfortBoilerEntity, SensorEntity):
|
||||
self._attr_unique_id = f"{heater.serial_no}_{description.key}"
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self._heater.status[self.entity_description.value_key] # type: ignore [no-any-return]
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the device state attributes."""
|
||||
if (extra_key := self.entity_description.extra_key) is None:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from incomfortclient import Heater as InComfortHeater
|
||||
|
||||
@@ -49,11 +49,13 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
self._attr_unique_id = heater.serial_no
|
||||
|
||||
@property
|
||||
@override
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS}
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
if self._heater.is_tapping:
|
||||
@@ -67,6 +69,7 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity):
|
||||
return max(self._heater.heater_temp, self._heater.tap_temp)
|
||||
|
||||
@property
|
||||
@override
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the current operation mode."""
|
||||
return self._heater.display_text
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["iometer==0.4.0"],
|
||||
"requirements": ["iometer==1.0.1"],
|
||||
"zeroconf": ["_iometer._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ class ISYLightEntity(ISYNodeEntity, LightEntity, RestoreEntity):
|
||||
def async_on_update(self, event: NodeProperty) -> None:
|
||||
"""Save brightness in the update event from the ISY Node."""
|
||||
if self._node.status not in (0, ISY_VALUE_UNKNOWN):
|
||||
self._last_brightness = self._node.status
|
||||
if self._node.uom == UOM_PERCENTAGE:
|
||||
self._last_brightness = round(self._node.status * 255.0 / 100.0)
|
||||
else:
|
||||
|
||||
@@ -279,10 +279,6 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
|
||||
if uom in (UOM_INDEX, UOM_ON_OFF):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Check if this is an index type and get formatted value
|
||||
if uom == UOM_INDEX and hasattr(self.target, "formatted"):
|
||||
return cast(str, self.target.formatted)
|
||||
|
||||
# Handle ISY precision and rounding
|
||||
value = convert_isy_value_to_hass(value, uom, self.target.prec)
|
||||
if value is None:
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
async_add_entities(device.zones.values())
|
||||
|
||||
# create any components not yet created
|
||||
for controller in disco.pi_disco.controllers.values():
|
||||
for controller in (await disco.pi_disco.fetch_controllers()).values():
|
||||
init_controller(controller)
|
||||
|
||||
# connect to register any further components
|
||||
|
||||
@@ -29,12 +29,13 @@ async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||
async with asyncio.timeout(TIMEOUT_DISCOVERY):
|
||||
await controller_ready.wait()
|
||||
|
||||
if not disco.pi_disco.controllers:
|
||||
controllers = await disco.pi_disco.fetch_controllers()
|
||||
if not controllers:
|
||||
await async_stop_discovery_service(hass)
|
||||
_LOGGER.debug("No controllers found")
|
||||
return False
|
||||
|
||||
_LOGGER.debug("Controllers %s", disco.pi_disco.controllers)
|
||||
_LOGGER.debug("Controllers %s", controllers)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import datetime
|
||||
from functools import partial
|
||||
from random import random
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.labs import (
|
||||
EventLabsUpdatedData,
|
||||
async_is_preview_feature_enabled,
|
||||
@@ -34,7 +32,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
@@ -51,9 +49,11 @@ from homeassistant.util.unit_conversion import (
|
||||
)
|
||||
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
Platform.BUTTON,
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.FAN,
|
||||
Platform.EVENT,
|
||||
Platform.IMAGE,
|
||||
@@ -69,15 +69,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forwards", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the demo environment."""
|
||||
@@ -87,24 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall | None = None) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"test_service_1",
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Demo platform that has a couple of fake device trackers."""
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
BaseScannerEntity,
|
||||
SourceType,
|
||||
TrackerEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Everything but the Kitchen Sink config entry."""
|
||||
async_add_entities(
|
||||
[
|
||||
DemoTracker(
|
||||
unique_id="kitchen_sink_tracker_001",
|
||||
name="Demo tracker",
|
||||
latitude=hass.config.latitude,
|
||||
longitude=hass.config.longitude,
|
||||
accuracy=10,
|
||||
),
|
||||
DemoScanner(
|
||||
unique_id="kitchen_sink_scanner_001",
|
||||
name="Demo scanner",
|
||||
is_connected=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DemoTracker(TrackerEntity):
|
||||
"""Representation of a demo tracker."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.GPS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
accuracy: float,
|
||||
) -> None:
|
||||
"""Initialize the tracker."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
|
||||
@callback
|
||||
def async_set_tracker_location(
|
||||
self, latitude: float, longitude: float, accuracy: float
|
||||
) -> None:
|
||||
"""Update the tracker location."""
|
||||
self._attr_latitude = latitude
|
||||
self._attr_longitude = longitude
|
||||
self._attr_location_accuracy = accuracy
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class DemoScanner(BaseScannerEntity):
|
||||
"""Representation of a demo scanner."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_source_type = SourceType.ROUTER
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unique_id: str,
|
||||
name: str,
|
||||
is_connected: bool,
|
||||
) -> None:
|
||||
"""Initialize the scanner."""
|
||||
self._attr_unique_id = unique_id
|
||||
self._attr_name = name
|
||||
self._is_connected = is_connected
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return true if the device is connected."""
|
||||
return self._is_connected
|
||||
|
||||
@callback
|
||||
def async_set_scanner_connected(self, connected: bool) -> None:
|
||||
"""Update the scanner connected state."""
|
||||
self._is_connected = connected
|
||||
self.async_write_ha_state()
|
||||
@@ -9,6 +9,12 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"service": "mdi:lan-connect"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"service": "mdi:map-marker"
|
||||
},
|
||||
"test_service_1": {
|
||||
"sections": {
|
||||
"additional_fields": "mdi:test-tube"
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Services for the Everything but the Kitchen Sink integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN
|
||||
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1 = vol.Schema(
|
||||
{
|
||||
vol.Required("field_1"): vol.Coerce(int),
|
||||
vol.Required("field_2"): vol.In(["off", "auto", "cool"]),
|
||||
vol.Optional("field_3"): vol.Coerce(int),
|
||||
vol.Optional("field_4"): vol.In(["forward", "reverse"]),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_TEST_SERVICE_1 = "test_service_1"
|
||||
SERVICE_SET_TRACKER_LOCATION = "set_tracker_location"
|
||||
SERVICE_SET_SCANNER_CONNECTED = "set_scanner_connected"
|
||||
|
||||
ATTR_ACCURACY = "accuracy"
|
||||
ATTR_CONNECTED = "connected"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for the Kitchen Sink integration."""
|
||||
|
||||
@callback
|
||||
def service_handler(call: ServiceCall) -> ServiceResponse:
|
||||
"""Do nothing."""
|
||||
return None
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_TEST_SERVICE_1,
|
||||
service_handler,
|
||||
SCHEMA_SERVICE_TEST_SERVICE_1,
|
||||
description_placeholders={
|
||||
"meep_1": "foo",
|
||||
"meep_2": "bar",
|
||||
"meep_3": "beer",
|
||||
"meep_4": "milk",
|
||||
"meep_5": "https://example.com",
|
||||
},
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_TRACKER_LOCATION,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_LATITUDE): cv.latitude,
|
||||
vol.Required(ATTR_LONGITUDE): cv.longitude,
|
||||
vol.Required(ATTR_ACCURACY): vol.All(vol.Coerce(float), vol.Range(min=0)),
|
||||
},
|
||||
func="async_set_tracker_location",
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_SCANNER_CONNECTED,
|
||||
entity_domain=DEVICE_TRACKER_DOMAIN,
|
||||
schema={vol.Required(ATTR_CONNECTED): cv.boolean},
|
||||
func="async_set_scanner_connected",
|
||||
)
|
||||
@@ -30,3 +30,44 @@ test_service_1:
|
||||
options:
|
||||
- "forward"
|
||||
- "reverse"
|
||||
set_tracker_location:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
latitude:
|
||||
required: true
|
||||
example: 52.379189
|
||||
selector:
|
||||
number:
|
||||
min: -90
|
||||
max: 90
|
||||
step: any
|
||||
longitude:
|
||||
required: true
|
||||
example: 4.899431
|
||||
selector:
|
||||
number:
|
||||
min: -180
|
||||
max: 180
|
||||
step: any
|
||||
accuracy:
|
||||
required: true
|
||||
example: 10
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 10000
|
||||
unit_of_measurement: m
|
||||
set_scanner_connected:
|
||||
target:
|
||||
entity:
|
||||
integration: kitchen_sink
|
||||
domain: device_tracker
|
||||
fields:
|
||||
connected:
|
||||
required: true
|
||||
example: true
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -135,6 +135,34 @@
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"set_scanner_connected": {
|
||||
"description": "Sets the connected state of a demo scanner entity.",
|
||||
"fields": {
|
||||
"connected": {
|
||||
"description": "Whether the device should be reported as connected.",
|
||||
"name": "Connected"
|
||||
}
|
||||
},
|
||||
"name": "Set scanner connected"
|
||||
},
|
||||
"set_tracker_location": {
|
||||
"description": "Sets the location and accuracy of a demo tracker entity.",
|
||||
"fields": {
|
||||
"accuracy": {
|
||||
"description": "Location accuracy in meters.",
|
||||
"name": "Accuracy"
|
||||
},
|
||||
"latitude": {
|
||||
"description": "Latitude of the new location.",
|
||||
"name": "Latitude"
|
||||
},
|
||||
"longitude": {
|
||||
"description": "Longitude of the new location.",
|
||||
"name": "Longitude"
|
||||
}
|
||||
},
|
||||
"name": "Set tracker location"
|
||||
},
|
||||
"test_service_1": {
|
||||
"description": "Fake action for testing {meep_2}",
|
||||
"fields": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -105,10 +105,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
|
||||
except ThinQAPIException as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
# pylint: disable-next=home-assistant-exception-message-with-translation
|
||||
raise ServiceValidationError(
|
||||
exc.message, translation_domain=DOMAIN, translation_key=exc.code
|
||||
) from exc
|
||||
raise ServiceValidationError(exc.message) from exc
|
||||
except ValueError as exc:
|
||||
if on_fail_method:
|
||||
on_fail_method()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -556,4 +556,48 @@ DISCOVERY_SCHEMAS = [
|
||||
featuremap_contains=clusters.Thermostat.Bitmaps.Feature.kOccupancy,
|
||||
allow_multi=True,
|
||||
),
|
||||
# GeneralDiagnostics active fault sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveHardwareFaults",
|
||||
translation_key="active_hardware_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(
|
||||
clusters.GeneralDiagnostics.Attributes.ActiveHardwareFaults,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveRadioFaults",
|
||||
translation_key="active_radio_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.ActiveRadioFaults,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
entity_description=MatterBinarySensorEntityDescription(
|
||||
key="GeneralDiagnosticsActiveNetworkFaults",
|
||||
translation_key="active_network_faults",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=bool,
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
required_attributes=(
|
||||
clusters.GeneralDiagnostics.Attributes.ActiveNetworkFaults,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -457,7 +457,14 @@ class MatterLight(MatterEntity, LightEntity):
|
||||
self._transitions_disabled = True
|
||||
LOGGER.warning(
|
||||
"Detected a device that has been reported to have firmware issues "
|
||||
"with light transitions. Transitions will be disabled for this light"
|
||||
"with light transitions. Transitions will be disabled for this "
|
||||
"light: %s %s (vendor_id: %s, product_id: %s, hw: %s, sw: %s)",
|
||||
device_info.vendorName,
|
||||
device_info.productName,
|
||||
device_info.vendorID,
|
||||
device_info.productID,
|
||||
device_info.hardwareVersionString,
|
||||
device_info.softwareVersionString,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -137,6 +137,17 @@ RVC_OPERATIONAL_STATE_ERROR_MAP = {
|
||||
_rvc_err.kNavigationSensorObscured: ("navigation_sensor_obscured"),
|
||||
}
|
||||
|
||||
BOOT_REASON_MAP = {
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnspecified: "unspecified",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kPowerOnReboot: "power_on_reboot",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kBrownOutReset: "brown_out_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareWatchdogReset: "software_watchdog_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kHardwareWatchdogReset: "hardware_watchdog_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareUpdateCompleted: "software_update_completed",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kSoftwareReset: "software_reset",
|
||||
clusters.GeneralDiagnostics.Enums.BootReasonEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
BOOST_STATE_MAP = {
|
||||
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kInactive: "inactive",
|
||||
clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: "active",
|
||||
@@ -428,6 +439,19 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="SoilMoistureSensor",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.MOISTURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(
|
||||
clusters.SoilMeasurement.Attributes.SoilMoistureMeasuredValue,
|
||||
),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
@@ -1575,4 +1599,46 @@ DISCOVERY_SCHEMAS = [
|
||||
required_attributes=(clusters.DoorLock.Attributes.DoorClosedEvents,),
|
||||
featuremap_contains=clusters.DoorLock.Bitmaps.Feature.kDoorPositionSensor,
|
||||
),
|
||||
# GeneralDiagnostics cluster sensors
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsRebootCount",
|
||||
translation_key="reboot_count",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.RebootCount,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsUpTime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
device_to_ha=lambda uptime: dt_util.utcnow() - timedelta(seconds=uptime),
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.UpTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="GeneralDiagnosticsBootReason",
|
||||
translation_key="boot_reason",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
options=[
|
||||
reason for reason in BOOT_REASON_MAP.values() if reason is not None
|
||||
],
|
||||
device_to_ha=BOOT_REASON_MAP.get,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.GeneralDiagnostics.Attributes.BootReason,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"active_hardware_faults": {
|
||||
"name": "Hardware faults"
|
||||
},
|
||||
"active_network_faults": {
|
||||
"name": "Network faults"
|
||||
},
|
||||
"active_radio_faults": {
|
||||
"name": "Radio faults"
|
||||
},
|
||||
"actuator": {
|
||||
"name": "Actuator"
|
||||
},
|
||||
@@ -408,6 +417,18 @@
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"boot_reason": {
|
||||
"name": "Boot reason",
|
||||
"state": {
|
||||
"brown_out_reset": "Brownout reset",
|
||||
"hardware_watchdog_reset": "Hardware watchdog reset",
|
||||
"power_on_reboot": "Power-on reboot",
|
||||
"software_reset": "Software reset",
|
||||
"software_update_completed": "Software update completed",
|
||||
"software_watchdog_reset": "Software watchdog reset",
|
||||
"unspecified": "Unspecified"
|
||||
}
|
||||
},
|
||||
"contamination_state": {
|
||||
"name": "Contamination state",
|
||||
"state": {
|
||||
@@ -576,6 +597,9 @@
|
||||
"reactive_current": {
|
||||
"name": "Reactive current"
|
||||
},
|
||||
"reboot_count": {
|
||||
"name": "Reboot count"
|
||||
},
|
||||
"rms_current": {
|
||||
"name": "Effective current"
|
||||
},
|
||||
@@ -600,6 +624,9 @@
|
||||
"medium": "[%key:common::state::medium%]"
|
||||
}
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -1140,7 +1140,7 @@
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.",
|
||||
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
|
||||
"title": "MQTT protocol change required"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
self._range, float(position_payload)
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Ignoring non numeric payload '%s' received on topic '%s'",
|
||||
position_payload,
|
||||
msg.topic,
|
||||
@@ -279,9 +279,9 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
else:
|
||||
percentage_payload = min(max(percentage_payload, 0), 100)
|
||||
self._attr_current_valve_position = percentage_payload
|
||||
# Reset closing and opening if the valve is fully opened or fully closed
|
||||
if state is None and percentage_payload in (0, 100):
|
||||
state = RESET_CLOSING_OPENING
|
||||
# Reset opening/closing when a position update is received
|
||||
# without an explicit opening/closing transitional state.
|
||||
state = state or RESET_CLOSING_OPENING
|
||||
position_set = True
|
||||
if state_payload and state is None and not position_set:
|
||||
_LOGGER.warning(
|
||||
@@ -291,8 +291,6 @@ class MqttValve(MqttEntity, ValveEntity):
|
||||
state_payload,
|
||||
)
|
||||
return
|
||||
if state is None:
|
||||
return
|
||||
self._update_state(state)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
default: each
|
||||
selector:
|
||||
automation_behavior:
|
||||
mode: trigger
|
||||
|
||||
@@ -52,6 +52,11 @@ class OMIEPriceSensor(CoordinatorEntity[OMIECoordinator], SensorEntity):
|
||||
self._attr_unique_id = pyomie_series_name
|
||||
self._pyomie_series_name = pyomie_series_name
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._handle_coordinator_update()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update this sensor's state from the coordinator results."""
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": [
|
||||
"onvif-zeep-async==4.1.0",
|
||||
"onvif-zeep-async==4.1.1",
|
||||
"onvif_parsers==2.3.0",
|
||||
"WSDiscovery==2.1.2"
|
||||
]
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Support for OPNsense Routers."""
|
||||
|
||||
import logging
|
||||
|
||||
from aiopnsense import (
|
||||
OPNsenseBelowMinFirmware,
|
||||
OPNsenseClient,
|
||||
@@ -15,22 +13,16 @@ from aiopnsense import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_API_SECRET,
|
||||
CONF_INTERFACE_CLIENT,
|
||||
CONF_TRACKER_INTERFACES,
|
||||
DOMAIN,
|
||||
OPNSENSE_DATA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import CONF_API_SECRET, CONF_TRACKER_INTERFACES, DOMAIN
|
||||
from .types import OPNsenseConfigEntry, OPNsenseRuntimeData
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -49,86 +41,124 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the opnsense component."""
|
||||
"""Set up the OPNsense component."""
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
conf = config[DOMAIN]
|
||||
url = conf[CONF_URL]
|
||||
api_key = conf[CONF_API_KEY]
|
||||
api_secret = conf[CONF_API_SECRET]
|
||||
verify_ssl = conf[CONF_VERIFY_SSL]
|
||||
tracker_interfaces = conf[CONF_TRACKER_INTERFACES]
|
||||
hass.async_create_task(_async_setup(hass, config))
|
||||
|
||||
session = async_get_clientsession(hass, verify_ssl=verify_ssl)
|
||||
return True
|
||||
|
||||
|
||||
async def _async_setup(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Set up the OPNsense component from YAML."""
|
||||
await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config[DOMAIN],
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the OPNsense component from a config entry."""
|
||||
url = config_entry.data[CONF_URL]
|
||||
session = async_get_clientsession(
|
||||
hass, verify_ssl=config_entry.data[CONF_VERIFY_SSL]
|
||||
)
|
||||
client = OPNsenseClient(
|
||||
url,
|
||||
api_key,
|
||||
api_secret,
|
||||
config_entry.data[CONF_API_KEY],
|
||||
config_entry.data[CONF_API_SECRET],
|
||||
session,
|
||||
opts={"verify_ssl": verify_ssl},
|
||||
opts={"verify_ssl": config_entry.data[CONF_VERIFY_SSL]},
|
||||
)
|
||||
tracker_interfaces = config_entry.data.get(CONF_TRACKER_INTERFACES, [])
|
||||
try:
|
||||
await client.validate()
|
||||
if tracker_interfaces:
|
||||
interfaces_resp = await client.get_interfaces()
|
||||
except OPNsenseUnknownFirmware:
|
||||
_LOGGER.error("Error checking the OPNsense firmware version at %s", url)
|
||||
return False
|
||||
except OPNsenseBelowMinFirmware:
|
||||
_LOGGER.error(
|
||||
"OPNsense Firmware is below the minimum supported version at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseInvalidURL:
|
||||
_LOGGER.error(
|
||||
"Invalid URL while connecting to OPNsense API endpoint at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseTimeoutError:
|
||||
_LOGGER.error("Timeout while connecting to OPNsense API endpoint at %s", url)
|
||||
return False
|
||||
except OPNsenseSSLError:
|
||||
_LOGGER.error(
|
||||
"Unable to verify SSL while connecting to OPNsense API endpoint at %s", url
|
||||
)
|
||||
return False
|
||||
except OPNsenseInvalidAuth:
|
||||
_LOGGER.error(
|
||||
"Authentication failure while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsensePrivilegeMissing:
|
||||
_LOGGER.error(
|
||||
"Invalid Permissions while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsenseConnectionError:
|
||||
_LOGGER.error(
|
||||
"Connection failure while connecting to OPNsense API endpoint at %s",
|
||||
url,
|
||||
)
|
||||
return False
|
||||
except OPNsenseUnknownFirmware as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unknown_firmware",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseBelowMinFirmware as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="firmware_too_old",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseInvalidURL as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_url",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseTimeoutError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connecting",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseSSLError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="ssl_error",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseInvalidAuth as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsensePrivilegeMissing as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="privilege_missing",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
except OPNsenseConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"url": url},
|
||||
) from err
|
||||
|
||||
if tracker_interfaces:
|
||||
# Verify that specified tracker interfaces are valid
|
||||
known_interfaces = [
|
||||
ifinfo.get("name", "") for ifinfo in interfaces_resp.values()
|
||||
name for ifinfo in interfaces_resp.values() if (name := ifinfo.get("name"))
|
||||
]
|
||||
for intf_description in tracker_interfaces:
|
||||
if intf_description not in known_interfaces:
|
||||
_LOGGER.error(
|
||||
"Specified OPNsense tracker interface %s is not found",
|
||||
intf_description,
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="tracker_interface_not_found",
|
||||
translation_placeholders={
|
||||
"interface": intf_description,
|
||||
"known": ", ".join(known_interfaces),
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
hass.data[OPNSENSE_DATA] = {
|
||||
CONF_INTERFACE_CLIENT: client,
|
||||
CONF_TRACKER_INTERFACES: tracker_interfaces,
|
||||
}
|
||||
config_entry.runtime_data = OPNsenseRuntimeData(
|
||||
client=client,
|
||||
tracker_interfaces=tracker_interfaces,
|
||||
)
|
||||
|
||||
load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, tracker_interfaces, config)
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: OPNsenseConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user