Compare commits

..

1 Commits

Author SHA1 Message Date
farmio
336ca369e4 clean up name config for yaml entities 2026-01-04 21:16:52 +01:00
1587 changed files with 6621 additions and 43455 deletions

View File

@@ -40,8 +40,7 @@
"python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
"python.analysis.typeCheckingMode": "basic",
"editor.formatOnPaste": false,
"editor.formatOnSave": true,
"editor.formatOnType": true,

View File

@@ -847,8 +847,8 @@ rules:
## Development Commands
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **Run all linters on all files**: `pre-commit run --all-files`
- **Run linters on staged files only**: `pre-commit run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
@@ -1024,6 +1024,18 @@ class MyCoordinator(DataUpdateCoordinator[MyData]):
)
```
### Entity Performance Optimization
```python
# Use __slots__ for memory efficiency
class MySensor(SensorEntity):
__slots__ = ("_attr_native_value", "_attr_available")
@property
def should_poll(self) -> bool:
"""Disable polling when using coordinator."""
return False # ✅ Let coordinator handle updates
```
## Testing Patterns
### Testing Best Practices
@@ -1169,4 +1181,4 @@ python -m script.hassfest --integration-path homeassistant/components/my_integra
pytest ./tests/components/my_integration \
--cov=homeassistant.components.my_integration \
--cov-report term-missing
```
```

View File

@@ -59,6 +59,7 @@ env:
# 15 is the latest version
# - 15.2 is the latest (as of 9 Feb 2023)
POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
UV_CACHE_DIR: /tmp/uv-cache
APT_CACHE_BASE: /home/runner/work/apt
APT_CACHE_DIR: /home/runner/work/apt/cache
@@ -82,6 +83,7 @@ jobs:
integrations_glob: ${{ steps.info.outputs.integrations_glob }}
integrations: ${{ steps.integrations.outputs.changes }}
apt_cache_key: ${{ steps.generate_apt_cache_key.outputs.key }}
pre-commit_cache_key: ${{ steps.generate_pre-commit_cache_key.outputs.key }}
python_cache_key: ${{ steps.generate_python_cache_key.outputs.key }}
requirements: ${{ steps.core.outputs.requirements }}
mariadb_groups: ${{ steps.info.outputs.mariadb_groups }}
@@ -109,6 +111,11 @@ jobs:
hashFiles('requirements_all.txt') }}-${{
hashFiles('homeassistant/package_constraints.txt') }}-${{
hashFiles('script/gen_requirements_all.py') }}" >> $GITHUB_OUTPUT
- name: Generate partial pre-commit restore key
id: generate_pre-commit_cache_key
run: >-
echo "key=pre-commit-${{ env.CACHE_VERSION }}-${{
hashFiles('.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Generate partial apt restore key
id: generate_apt_cache_key
run: |
@@ -237,8 +244,8 @@ jobs:
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
prek:
name: Run prek checks
pre-commit:
name: Prepare pre-commit base
runs-on: *runs-on-ubuntu
needs: [info]
if: |
@@ -247,23 +254,147 @@ jobs:
&& github.event.inputs.audit-licenses-only != 'true'
steps:
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
key: &key-pre-commit-venv >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-venv-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
pip install "$(grep '^uv' < requirements.txt)"
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
key: &key-pre-commit-env >-
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.pre-commit_cache_key }}
- name: Install pre-commit dependencies
if: steps.cache-precommit.outputs.cache-hit != 'true'
run: |
. venv/bin/activate
pre-commit install-hooks
lint-ruff-format:
name: Check ruff-format
runs-on: *runs-on-ubuntu
needs: &needs-pre-commit
- info
- pre-commit
steps:
- *checkout
- *setup-python-default
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: venv
fail-on-cache-miss: true
key: *key-pre-commit-venv
- &cache-restore-pre-commit-env
name: Restore pre-commit environment from cache
id: cache-precommit
uses: *actions-cache-restore
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
key: *key-pre-commit-env
- name: Run ruff-format
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-format --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-ruff:
name: Check ruff
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Run ruff
run: |
. venv/bin/activate
pre-commit run --hook-stage manual ruff-check --all-files --show-diff-on-failure
env:
RUFF_OUTPUT_FORMAT: github
lint-other:
name: Check other linters
runs-on: *runs-on-ubuntu
needs: *needs-pre-commit
steps:
- *checkout
- *setup-python-default
- *cache-restore-pre-commit-venv
- *cache-restore-pre-commit-env
- name: Register yamllint problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
- name: Run yamllint
run: |
. venv/bin/activate
pre-commit run --hook-stage manual yamllint --all-files --show-diff-on-failure
- name: Register check-json problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-json.json"
- name: Run check-json
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-json --all-files --show-diff-on-failure
- name: Run prettier (fully)
if: needs.info.outputs.test_full_suite == 'true'
run: |
. venv/bin/activate
pre-commit run --hook-stage manual prettier --all-files --show-diff-on-failure
- name: Run prettier (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
run: |
. venv/bin/activate
shopt -s globstar
pre-commit run --hook-stage manual prettier --show-diff-on-failure --files {homeassistant,tests}/components/${{ needs.info.outputs.integrations_glob }}/{*,**/*}
- name: Register check executables problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
- name: Run executables check
run: |
. venv/bin/activate
pre-commit run --hook-stage manual check-executables-have-shebangs --all-files --show-diff-on-failure
- name: Register codespell problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config
RUFF_OUTPUT_FORMAT: github
- name: Run codespell
run: |
. venv/bin/activate
pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files
lint-hadolint:
name: Check ${{ matrix.file }}
@@ -303,7 +434,7 @@ jobs:
- &setup-python-matrix
name: Set up Python ${{ matrix.python-version }}
id: python
uses: &actions-setup-python actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: *actions-setup-python
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -316,7 +447,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: *actions-cache
with:
path: venv
key: &key-python-venv >-
@@ -431,7 +562,7 @@ jobs:
steps:
- &cache-restore-apt
name: Restore apt cache
uses: &actions-cache-restore actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: *actions-cache-restore
with:
path: *path-apt-cache
fail-on-cache-miss: true
@@ -448,13 +579,7 @@ jobs:
-o Dir::State::Lists=${{ env.APT_LIST_CACHE_DIR }} \
libturbojpeg
- *checkout
- &setup-python-default
name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: *actions-setup-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- *setup-python-default
- &cache-restore-python-default
name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
@@ -657,7 +782,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
steps:
- *cache-restore-apt
@@ -696,7 +823,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
- prepare-pytest-full
if: |
@@ -820,7 +949,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -935,7 +1066,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'
@@ -1069,7 +1202,9 @@ jobs:
- base
- gen-requirements-all
- hassfest
- prek
- lint-other
- lint-ruff
- lint-ruff-format
- mypy
if: |
needs.info.outputs.lint_only != 'true'

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
category: "/language:python"

View File

@@ -39,14 +39,14 @@ repos:
- id: prettier
additional_dependencies:
- prettier@3.6.2
- prettier-plugin-sort-json@4.2.0
- prettier-plugin-sort-json@4.1.1
- repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0
hooks:
# Run `python-typing-update` hook manually from time to time
# to update python typing syntax.
# Will require manual work, before submitting changes!
# prek run --hook-stage manual python-typing-update --all-files
# pre-commit run --hook-stage manual python-typing-update --all-files
- id: python-typing-update
stages: [manual]
args:

View File

@@ -407,7 +407,6 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.*
homeassistant.components.ping.*
homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
homeassistant.components.portainer.*
homeassistant.components.powerfox.*
homeassistant.components.powerwall.*

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
"python.analysis.typeCheckingMode": "off",
// Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "basic",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
},

8
.vscode/tasks.json vendored
View File

@@ -45,7 +45,7 @@
{
"label": "Ruff",
"type": "shell",
"command": "prek run ruff-check --all-files",
"command": "pre-commit run ruff-check --all-files",
"group": {
"kind": "test",
"isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": []
},
{
"label": "Prek",
"label": "Pre-commit",
"type": "shell",
"command": "prek run --show-diff-on-failure",
"command": "pre-commit run --show-diff-on-failure",
"group": {
"kind": "test",
"isDefault": true
@@ -120,7 +120,7 @@
{
"label": "Generate Requirements",
"type": "shell",
"command": "${command:python.interpreterPath} -m script.gen_requirements_all",
"command": "./script/gen_requirements_all.py",
"group": {
"kind": "build",
"isDefault": true

7
CODEOWNERS generated
View File

@@ -661,8 +661,6 @@ build.json @home-assistant/supervisor
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
/tests/components/hassio/ @home-assistant/supervisor
/homeassistant/components/hdfury/ @glenndehaan
/tests/components/hdfury/ @glenndehaan
/homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran
@@ -1068,8 +1066,6 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r
/tests/components/namecheapdns/ @tr4nt0r
/homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nasweb/ @nasWebio
@@ -1174,8 +1170,6 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen
@@ -1807,7 +1801,6 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai

View File

@@ -7,12 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: AirobotConfigEntry) -> bool:

View File

@@ -1,96 +0,0 @@
"""Button platform for Airobot integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pyairobotrest.exceptions import (
AirobotConnectionError,
AirobotError,
AirobotTimeoutError,
)
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import AirobotConfigEntry, AirobotDataUpdateCoordinator
from .entity import AirobotEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class AirobotButtonEntityDescription(ButtonEntityDescription):
"""Describes Airobot button entity."""
press_fn: Callable[[AirobotDataUpdateCoordinator], Coroutine[Any, Any, None]]
BUTTON_TYPES: tuple[AirobotButtonEntityDescription, ...] = (
AirobotButtonEntityDescription(
key="restart",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_fn=lambda coordinator: coordinator.client.reboot_thermostat(),
),
AirobotButtonEntityDescription(
key="recalibrate_co2",
translation_key="recalibrate_co2",
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
press_fn=lambda coordinator: coordinator.client.recalibrate_co2_sensor(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airobot button entities."""
coordinator = entry.runtime_data
async_add_entities(
AirobotButton(coordinator, description) for description in BUTTON_TYPES
)
class AirobotButton(AirobotEntity, ButtonEntity):
"""Representation of an Airobot button."""
entity_description: AirobotButtonEntityDescription
def __init__(
self,
coordinator: AirobotDataUpdateCoordinator,
description: AirobotButtonEntityDescription,
) -> None:
"""Initialize the button."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.status.device_id}_{description.key}"
async def async_press(self) -> None:
"""Handle the button press."""
try:
await self.entity_description.press_fn(self.coordinator)
except (AirobotConnectionError, AirobotTimeoutError):
# Connection errors during reboot are expected as device restarts
pass
except AirobotError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
translation_placeholders={"button": self.entity_description.key},
) from err

View File

@@ -1,10 +1,5 @@
{
"entity": {
"button": {
"recalibrate_co2": {
"default": "mdi:molecule-co2"
}
},
"number": {
"hysteresis_band": {
"default": "mdi:delta"

View File

@@ -12,6 +12,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "gold",
"quality_scale": "silver",
"requirements": ["pyairobotrest==0.2.0"]
}

View File

@@ -43,7 +43,7 @@ rules:
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done

View File

@@ -59,11 +59,6 @@
}
},
"entity": {
"button": {
"recalibrate_co2": {
"name": "Recalibrate CO2 sensor"
}
},
"number": {
"hysteresis_band": {
"name": "Hysteresis band"
@@ -91,9 +86,6 @@
"authentication_failed": {
"message": "Authentication failed, please reauthenticate."
},
"button_press_failed": {
"message": "Failed to press {button} button."
},
"connection_failed": {
"message": "Failed to communicate with device."
},

View File

@@ -85,22 +85,6 @@ class AirzoneSystemEntity(AirzoneEntity):
value = system[key]
return value
async def _async_update_sys_params(self, params: dict[str, Any]) -> None:
"""Send system parameters to API."""
_params = {
API_SYSTEM_ID: self.system_id,
**params,
}
_LOGGER.debug("update_sys_params=%s", _params)
try:
await self.coordinator.airzone.set_sys_parameters(_params)
except AirzoneError as error:
raise HomeAssistantError(
f"Failed to set system {self.entity_id}: {error}"
) from error
self.coordinator.async_set_updated_data(self.coordinator.airzone.data())
class AirzoneHotWaterEntity(AirzoneEntity):
"""Define an Airzone Hot Water entity."""

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.5"]
"requirements": ["aioairzone==1.0.4"]
}

View File

@@ -20,7 +20,6 @@ from aioairzone.const import (
AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP,
AZD_SYSTEMS,
AZD_ZONES,
)
@@ -31,7 +30,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
from .entity import AirzoneEntity, AirzoneZoneEntity
@dataclass(frozen=True, kw_only=True)
@@ -86,18 +85,6 @@ def main_zone_options(
return [k for k, v in options.items() if v in modes]
SYSTEM_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
)
MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
AirzoneSelectDescription(
api_param=API_MODE,
@@ -106,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
options_fn=main_zone_options,
translation_key="modes",
),
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
)
@@ -145,37 +140,16 @@ async def async_setup_entry(
"""Add Airzone select from a config_entry."""
coordinator = entry.runtime_data
added_systems: set[str] = set()
added_zones: set[str] = set()
def _async_entity_listener() -> None:
"""Handle additions of select."""
entities: list[AirzoneBaseSelect] = []
systems_data = coordinator.data.get(AZD_SYSTEMS, {})
received_systems = set(systems_data)
new_systems = received_systems - added_systems
if new_systems:
entities.extend(
AirzoneSystemSelect(
coordinator,
description,
entry,
system_id,
systems_data.get(system_id),
)
for system_id in new_systems
for description in SYSTEM_SELECT_TYPES
if description.key in systems_data.get(system_id)
)
added_systems.update(new_systems)
zones_data = coordinator.data.get(AZD_ZONES, {})
received_zones = set(zones_data)
new_zones = received_zones - added_zones
if new_zones:
entities.extend(
entities: list[AirzoneZoneSelect] = [
AirzoneZoneSelect(
coordinator,
description,
@@ -187,8 +161,8 @@ async def async_setup_entry(
for description in MAIN_ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
and zones_data.get(system_zone_id).get(AZD_MASTER) is True
)
entities.extend(
]
entities += [
AirzoneZoneSelect(
coordinator,
description,
@@ -199,11 +173,10 @@ async def async_setup_entry(
for system_zone_id in new_zones
for description in ZONE_SELECT_TYPES
if description.key in zones_data.get(system_zone_id)
)
]
async_add_entities(entities)
added_zones.update(new_zones)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
_async_entity_listener()
@@ -230,38 +203,6 @@ class AirzoneBaseSelect(AirzoneEntity, SelectEntity):
self._attr_current_option = self._get_current_option()
class AirzoneSystemSelect(AirzoneSystemEntity, AirzoneBaseSelect):
"""Define an Airzone System select."""
def __init__(
self,
coordinator: AirzoneUpdateCoordinator,
description: AirzoneSelectDescription,
entry: ConfigEntry,
system_id: str,
system_data: dict[str, Any],
) -> None:
"""Initialize."""
super().__init__(coordinator, entry, system_data)
self._attr_unique_id = f"{self._attr_unique_id}_{system_id}_{description.key}"
self.entity_description = description
self._attr_options = self.entity_description.options_fn(
system_data, description.options_dict
)
self.values_dict = {v: k for k, v in description.options_dict.items()}
self._async_update_attrs()
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
param = self.entity_description.api_param
value = self.entity_description.options_dict[option]
await self._async_update_sys_params({param: value})
class AirzoneZoneSelect(AirzoneZoneEntity, AirzoneBaseSelect):
"""Define an Airzone Zone select."""

View File

@@ -1,93 +0,0 @@
"""Provides conditions for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.condition import (
Condition,
EntityStateConditionBase,
make_entity_state_condition,
)
from homeassistant.helpers.entity import get_supported_features
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_required_features_condition(
domain: str, to_state: str, required_features: int
) -> type[EntityStateRequiredFeaturesCondition]:
"""Create an entity state condition class with required feature filtering."""
class CustomCondition(EntityStateRequiredFeaturesCondition):
"""Condition for entity state changes."""
_domain = domain
_states = {to_state}
_required_features = required_features
return CustomCondition
CONDITIONS: dict[str, type[Condition]] = {
"is_armed": make_entity_state_condition(
DOMAIN,
{
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"is_armed_away": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"is_armed_home": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"is_armed_night": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"is_armed_vacation": make_entity_state_required_features_condition(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the alarm control panel conditions."""
return CONDITIONS

View File

@@ -1,52 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_armed: *condition_common
is_armed_away:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common
is_triggered: *condition_common

View File

@@ -1,27 +1,4 @@
{
"conditions": {
"is_armed": {
"condition": "mdi:shield"
},
"is_armed_away": {
"condition": "mdi:shield-lock"
},
"is_armed_home": {
"condition": "mdi:shield-home"
},
"is_armed_night": {
"condition": "mdi:shield-moon"
},
"is_armed_vacation": {
"condition": "mdi:shield-airplane"
},
"is_disarmed": {
"condition": "mdi:shield-off"
},
"is_triggered": {
"condition": "mdi:bell-ring"
}
},
"entity_component": {
"_": {
"default": "mdi:shield",

View File

@@ -1,82 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted alarms.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_armed": {
"description": "Tests if one or more alarms are armed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed"
},
"is_armed_away": {
"description": "Tests if one or more alarms are armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed away"
},
"is_armed_home": {
"description": "Tests if one or more alarms are armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed home"
},
"is_armed_night": {
"description": "Tests if one or more alarms are armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed night"
},
"is_armed_vacation": {
"description": "Tests if one or more alarms are armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is armed vacation"
},
"is_disarmed": {
"description": "Tests if one or more alarms are disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is disarmed"
},
"is_triggered": {
"description": "Tests if one or more alarms are triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
"name": "If an alarm is triggered"
}
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -150,12 +76,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Test if an entity supports the specified features."""
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityTargetStateTriggerBase]:
"""Create an entity state trigger class with required feature filtering."""
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""

View File

@@ -69,7 +69,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.json import json_dumps
from homeassistant.util import slugify
from . import AnthropicConfigEntry
@@ -194,7 +193,7 @@ def _convert_content(
tool_result_block = ToolResultBlockParam(
type="tool_result",
tool_use_id=content.tool_call_id,
content=json_dumps(content.tool_result),
content=json.dumps(content.tool_result),
)
external_tool = False
if not messages or messages[-1]["role"] != (

View File

@@ -4,8 +4,6 @@ from __future__ import annotations
import logging
import dateutil
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
from homeassistant.components.sensor import (
@@ -181,7 +179,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
LAST_S_TEST: SensorEntityDescription(
key=LAST_S_TEST,
translation_key="last_self_test",
device_class=SensorDeviceClass.TIMESTAMP,
),
"lastxfer": SensorEntityDescription(
key="lastxfer",
@@ -235,7 +232,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
"masterupd": SensorEntityDescription(
key="masterupd",
translation_key="master_update",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"maxlinev": SensorEntityDescription(
@@ -369,7 +365,6 @@ SENSORS: dict[str, SensorEntityDescription] = {
"starttime": SensorEntityDescription(
key="starttime",
translation_key="startup_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"statflag": SensorEntityDescription(
@@ -421,19 +416,16 @@ SENSORS: dict[str, SensorEntityDescription] = {
"xoffbat": SensorEntityDescription(
key="xoffbat",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xoffbatt": SensorEntityDescription(
key="xoffbatt",
translation_key="transfer_from_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
"xonbatt": SensorEntityDescription(
key="xonbatt",
translation_key="transfer_to_battery",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
),
}
@@ -537,13 +529,7 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
self._attr_native_value = None
return
data = self.coordinator.data[key]
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dateutil.parser.parse(data)
return
self._attr_native_value, inferred_unit = infer_unit(data)
self._attr_native_value, inferred_unit = infer_unit(self.coordinator.data[key])
if not self.native_unit_of_measurement:
self._attr_native_unit_of_measurement = inferred_unit

View File

@@ -3,8 +3,9 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
import logging
import math
from pymicro_vad import MicroVad
from pysilero_vad import SileroVoiceActivityDetector
from pyspeex_noise import AudioProcessor
from .const import BYTES_PER_CHUNK
@@ -42,8 +43,8 @@ class AudioEnhancer(ABC):
"""Enhance chunk of PCM audio @ 16Khz with 16-bit mono samples."""
class MicroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs microVAD and speex."""
class SileroVadSpeexEnhancer(AudioEnhancer):
"""Audio enhancer that runs Silero VAD and speex."""
def __init__(
self, auto_gain: int, noise_suppression: int, is_vad_enabled: bool
@@ -69,21 +70,49 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
self.noise_suppression,
)
self.vad: MicroVad | None = None
self.vad: SileroVoiceActivityDetector | None = None
# We get 10ms chunks but Silero works on 32ms chunks, so we have to
# buffer audio. The previous speech probability is used until enough
# audio has been buffered.
self._vad_buffer: bytearray | None = None
self._vad_buffer_chunks = 0
self._vad_buffer_chunk_idx = 0
self._last_speech_probability: float | None = None
if self.is_vad_enabled:
self.vad = MicroVad()
_LOGGER.debug("Initialized microVAD")
self.vad = SileroVoiceActivityDetector()
# VAD buffer is a multiple of 10ms, but Silero VAD needs 32ms.
self._vad_buffer_chunks = int(
math.ceil(self.vad.chunk_bytes() / BYTES_PER_CHUNK)
)
self._vad_leftover_bytes = self.vad.chunk_bytes() - BYTES_PER_CHUNK
self._vad_buffer = bytearray(self.vad.chunk_bytes())
_LOGGER.debug("Initialized Silero VAD")
def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk:
"""Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples."""
speech_probability: float | None = None
assert len(audio) == BYTES_PER_CHUNK
if self.vad is not None:
# Run VAD
speech_probability = self.vad.Process10ms(audio)
assert self._vad_buffer is not None
start_idx = self._vad_buffer_chunk_idx * BYTES_PER_CHUNK
self._vad_buffer[start_idx : start_idx + BYTES_PER_CHUNK] = audio
self._vad_buffer_chunk_idx += 1
if self._vad_buffer_chunk_idx >= self._vad_buffer_chunks:
# We have enough data to run Silero VAD (32 ms)
self._last_speech_probability = self.vad.process_chunk(
self._vad_buffer[: self.vad.chunk_bytes()]
)
# Copy leftover audio that wasn't processed to start
self._vad_buffer[: self._vad_leftover_bytes] = self._vad_buffer[
-self._vad_leftover_bytes :
]
self._vad_buffer_chunk_idx = 0
if self.audio_processor is not None:
# Run noise suppression and auto gain
@@ -92,5 +121,5 @@ class MicroVadSpeexEnhancer(AudioEnhancer):
return EnhancedAudioChunk(
audio=audio,
timestamp_ms=timestamp_ms,
speech_probability=speech_probability,
speech_probability=self._last_speech_probability,
)

View File

@@ -8,5 +8,5 @@
"integration_type": "system",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["pymicro-vad==1.0.1", "pyspeex-noise==1.0.2"]
"requirements": ["pysilero-vad==3.0.1", "pyspeex-noise==1.0.2"]
}

View File

@@ -55,7 +55,7 @@ from homeassistant.util import (
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.limited_size_dict import LimitedSizeDict
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, MicroVadSpeexEnhancer
from .audio_enhancer import AudioEnhancer, EnhancedAudioChunk, SileroVadSpeexEnhancer
from .const import (
ACKNOWLEDGE_PATH,
BYTES_PER_CHUNK,
@@ -633,7 +633,7 @@ class PipelineRun:
# Initialize with audio settings
if self.audio_settings.needs_processor and (self.audio_enhancer is None):
# Default audio enhancer
self.audio_enhancer = MicroVadSpeexEnhancer(
self.audio_enhancer = SileroVadSpeexEnhancer(
self.audio_settings.auto_gain_dbfs,
self.audio_settings.noise_suppression_level,
self.audio_settings.is_vad_enabled,

View File

@@ -1,23 +0,0 @@
"""Provides conditions for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the assist satellite conditions."""
return CONDITIONS

View File

@@ -1,19 +0,0 @@
.condition_common: &condition_common
target:
entity:
domain: assist_satellite
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_idle: *condition_common
is_listening: *condition_common
is_processing: *condition_common
is_responding: *condition_common

View File

@@ -1,18 +1,4 @@
{
"conditions": {
"is_idle": {
"condition": "mdi:chat-sleep"
},
"is_listening": {
"condition": "mdi:chat-question"
},
"is_processing": {
"condition": "mdi:chat-processing"
},
"is_responding": {
"condition": "mdi:chat-alert"
}
},
"entity_component": {
"_": {
"default": "mdi:account-voice"

View File

@@ -1,52 +1,8 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_idle": {
"description": "Tests if one or more Assist satellites are idle.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is idle"
},
"is_listening": {
"description": "Tests if one or more Assist satellites are listening.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is listening"
},
"is_processing": {
"description": "Tests if one or more Assist satellites are processing.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is processing"
},
"is_responding": {
"description": "Tests if one or more Assist satellites are responding.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
"name": "If a satellite is responding"
}
},
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -65,12 +21,6 @@
"sentences": "Sentences"
}
},
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -7,7 +7,7 @@ import asyncio
from collections.abc import Callable, Mapping
from dataclasses import dataclass
import logging
from typing import Any, Literal, Protocol, cast
from typing import Any, Protocol, cast
from propcache.api import cached_property
import voluptuous as vol
@@ -16,10 +16,7 @@ from homeassistant.components import labs, websocket_api
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.components.labs import async_listen as async_labs_listen
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_ENTITY_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
ATTR_MODE,
ATTR_NAME,
CONF_ACTIONS,
@@ -33,7 +30,6 @@ from homeassistant.const import (
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TARGET,
CONF_TRIGGERS,
CONF_VARIABLES,
CONF_ZONE,
@@ -123,9 +119,6 @@ SERVICE_TRIGGER = "trigger"
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"fan",
"light",
}
@@ -143,7 +136,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"person",
"scene",
"siren",
"switch",
@@ -596,32 +588,20 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
"""Return True if entity is on."""
return self._async_detach_triggers is not None or self._is_enabled
@cached_property
@property
def referenced_labels(self) -> set[str]:
"""Return a set of referenced labels."""
referenced = self.action_script.referenced_labels
return self.action_script.referenced_labels
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_LABEL_ID))
return referenced
@cached_property
@property
def referenced_floors(self) -> set[str]:
"""Return a set of referenced floors."""
referenced = self.action_script.referenced_floors
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_FLOOR_ID))
return referenced
return self.action_script.referenced_floors
@cached_property
def referenced_areas(self) -> set[str]:
"""Return a set of referenced areas."""
referenced = self.action_script.referenced_areas
for conf in self._trigger_config:
referenced |= set(_get_targets_from_trigger_config(conf, ATTR_AREA_ID))
return referenced
return self.action_script.referenced_areas
@property
def referenced_blueprint(self) -> str | None:
@@ -1229,9 +1209,6 @@ def _trigger_extract_devices(trigger_conf: dict) -> list[str]:
if trigger_conf[CONF_PLATFORM] == "tag" and CONF_DEVICE_ID in trigger_conf:
return trigger_conf[CONF_DEVICE_ID] # type: ignore[no-any-return]
if target_devices := _get_targets_from_trigger_config(trigger_conf, CONF_DEVICE_ID):
return target_devices
return []
@@ -1262,28 +1239,9 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
):
return [trigger_conf[CONF_EVENT_DATA][CONF_ENTITY_ID]]
if target_entities := _get_targets_from_trigger_config(
trigger_conf, CONF_ENTITY_ID
):
return target_entities
return []
@callback
def _get_targets_from_trigger_config(
config: dict,
target: Literal["entity_id", "device_id", "area_id", "floor_id", "label_id"],
) -> list[str]:
"""Extract targets from a target config."""
if not (target_conf := config.get(CONF_TARGET)):
return []
if not (targets := target_conf.get(target)):
return []
return [targets] if isinstance(targets, str) else targets
@websocket_api.websocket_command({"type": "automation/config", "entity_id": str})
def websocket_config(
hass: HomeAssistant,

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import json
import logging
from typing import Any
from azure.servicebus import ServiceBusMessage
from azure.servicebus.aio import ServiceBusClient, ServiceBusSender
@@ -93,7 +92,7 @@ class ServiceBusNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
async def async_send_message(self, message: str, **kwargs: Any) -> None:
async def async_send_message(self, message, **kwargs):
"""Send a message."""
dto = {ATTR_ASB_MESSAGE: message}

View File

@@ -36,10 +36,6 @@ _LOGGER = logging.getLogger(__name__)
# Cache TTL for backup list (in seconds)
CACHE_TTL = 300
# Timeout for upload operations (in seconds)
# This prevents uploads from hanging indefinitely
UPLOAD_TIMEOUT = 43200 # 12 hours (matches B2 HTTP timeout)
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata files."""
@@ -333,28 +329,13 @@ class BackblazeBackupAgent(BackupAgent):
_LOGGER.debug("Uploading backup file %s with streaming", filename)
try:
content_type, _ = mimetypes.guess_type(filename)
file_version = await asyncio.wait_for(
self._hass.async_add_executor_job(
self._upload_unbound_stream_sync,
reader,
filename,
content_type or "application/x-tar",
file_info,
),
timeout=UPLOAD_TIMEOUT,
file_version = await self._hass.async_add_executor_job(
self._upload_unbound_stream_sync,
reader,
filename,
content_type or "application/x-tar",
file_info,
)
except TimeoutError:
_LOGGER.error(
"Upload of %s timed out after %s seconds", filename, UPLOAD_TIMEOUT
)
reader.abort()
raise BackupAgentError(
f"Upload timed out after {UPLOAD_TIMEOUT} seconds"
) from None
except asyncio.CancelledError:
_LOGGER.warning("Upload of %s was cancelled", filename)
reader.abort()
raise
finally:
reader.close()

View File

@@ -34,12 +34,7 @@ class BeoData:
type BeoConfigEntry = ConfigEntry[BeoData]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.EVENT,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
]
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool:

View File

@@ -1,63 +0,0 @@
"""Binary Sensor entities for the Bang & Olufsen integration."""
from __future__ import annotations
from mozart_api.models import BatteryState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
from .entity import BeoEntity
from .util import supports_battery
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BeoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Binary Sensor entities from config entry."""
if await supports_battery(config_entry.runtime_data.client):
async_add_entities(new_entities=[BeoBinarySensorBatteryCharging(config_entry)])
class BeoBinarySensorBatteryCharging(BinarySensorEntity, BeoEntity):
"""Battery charging Binary Sensor."""
_attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING
_attr_is_on = False
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Init the battery charging Binary Sensor."""
super().__init__(config_entry, config_entry.runtime_data.client)
self._attr_unique_id = f"{self._unique_id}_charging"
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
self._update_battery_charging,
)
)
async def _update_battery_charging(self, data: BatteryState) -> None:
"""Update battery charging."""
self._attr_is_on = bool(data.is_charging)
self.async_write_ha_state()

View File

@@ -115,7 +115,6 @@ class WebsocketNotification(StrEnum):
"""Enum for WebSocket notification types."""
ACTIVE_LISTENING_MODE = "active_listening_mode"
BATTERY = "battery"
BEO_REMOTE_BUTTON = "beo_remote_button"
BUTTON = "button"
PLAYBACK_ERROR = "playback_error"

View File

@@ -4,10 +4,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -57,19 +55,6 @@ async def async_get_config_entry_diagnostics(
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get Battery Sensor states
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_remote_battery_level",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_battery_level"] = state_dict
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
@@ -87,26 +72,4 @@ async def async_get_config_entry_diagnostics(
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
# Get Mozart battery entity
if entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_battery_level"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["battery_level"] = state_dict
# Get Mozart battery charging entity
if entity_id := entity_registry.async_get_entity_id(
BINARY_SENSOR_DOMAIN, DOMAIN, f"{config_entry.unique_id}_charging"
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data["charging"] = state_dict
return data

View File

@@ -1,139 +0,0 @@
"""Sensor entities for the Bang & Olufsen integration."""
from __future__ import annotations
import contextlib
from datetime import timedelta
from aiohttp import ClientConnectorError
from mozart_api.exceptions import ApiException
from mozart_api.models import BatteryState, PairedRemote
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import CONNECTION_STATUS, DOMAIN, WebsocketNotification
from .entity import BeoEntity
from .util import get_remotes, supports_battery
SCAN_INTERVAL = timedelta(minutes=15)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BeoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
entities: list[BeoSensor] = []
# Check for Mozart device with battery
if await supports_battery(config_entry.runtime_data.client):
entities.append(BeoSensorBatteryLevel(config_entry))
# Add any Beoremote One remotes
entities.extend(
[
BeoSensorRemoteBatteryLevel(config_entry, remote)
for remote in (await get_remotes(config_entry.runtime_data.client))
]
)
async_add_entities(entities, update_before_add=True)
class BeoSensor(SensorEntity, BeoEntity):
"""Base Bang & Olufsen Sensor."""
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Initialize Sensor."""
super().__init__(config_entry, config_entry.runtime_data.client)
class BeoSensorBatteryLevel(BeoSensor):
"""Battery level Sensor for Mozart devices."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
self._attr_unique_id = f"{self._unique_id}_battery_level"
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
self._update_battery,
)
)
async def _update_battery(self, data: BatteryState) -> None:
"""Update sensor value."""
self._attr_native_value = data.battery_level
self.async_write_ha_state()
class BeoSensorRemoteBatteryLevel(BeoSensor):
"""Battery level Sensor for the Beoremote One."""
_attr_device_class = SensorDeviceClass.BATTERY
_attr_native_unit_of_measurement = PERCENTAGE
_attr_should_poll = True
_attr_state_class = SensorStateClass.MEASUREMENT
def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None:
"""Init the battery level Sensor."""
super().__init__(config_entry)
# Serial number is not None, as the remote object is provided by get_remotes
assert remote.serial_number
self._attr_unique_id = (
f"{remote.serial_number}_{self._unique_id}_remote_battery_level"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}
)
self._attr_native_value = remote.battery_level
self._remote = remote
async def async_added_to_hass(self) -> None:
"""Turn on the dispatchers."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{DOMAIN}_{self._unique_id}_{CONNECTION_STATUS}",
self._async_update_connection_state,
)
)
async def async_update(self) -> None:
"""Poll battery status."""
with contextlib.suppress(ApiException, ClientConnectorError, TimeoutError):
for remote in await get_remotes(self._client):
if remote.serial_number == self._remote.serial_number:
self._attr_native_value = remote.battery_level
break

View File

@@ -84,10 +84,3 @@ def get_remote_keys() -> list[str]:
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]
async def supports_battery(client: MozartClient) -> bool:
"""Get if a Mozart device has a battery."""
battery_state = await client.get_battery_state()
return battery_state.state != "BatteryNotPresent"

View File

@@ -6,7 +6,6 @@ import logging
from typing import TYPE_CHECKING
from mozart_api.models import (
BatteryState,
BeoRemoteButton,
ButtonEvent,
ListeningModeProps,
@@ -61,7 +60,6 @@ class BeoWebsocket(BeoBase):
self._client.get_active_listening_mode_notifications(
self.on_active_listening_mode
)
self._client.get_battery_notifications(self.on_battery_notification)
self._client.get_beo_remote_button_notifications(
self.on_beo_remote_button_notification
)
@@ -117,14 +115,6 @@ class BeoWebsocket(BeoBase):
notification,
)
def on_battery_notification(self, notification: BatteryState) -> None:
"""Send battery dispatch."""
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self._unique_id}_{WebsocketNotification.BATTERY}",
notification,
)
def on_beo_remote_button_notification(self, notification: BeoRemoteButton) -> None:
"""Send beo_remote_button dispatch."""
if TYPE_CHECKING:

View File

@@ -1,7 +1,5 @@
"""BleBox sensor entities."""
from datetime import datetime
import blebox_uniapi.sensor
from homeassistant.components.sensor import (
@@ -148,7 +146,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
return self._feature.native_value
@property
def last_reset(self) -> datetime | None:
def last_reset(self):
"""Return the time when the sensor was last reset, if implemented."""
native_implementation = getattr(self._feature, "last_reset", None)

View File

@@ -64,7 +64,6 @@ def _ws_with_blueprint_domain(
return with_domain_blueprints
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/list",
@@ -98,7 +97,6 @@ async def ws_list_blueprints(
connection.send_result(msg["id"], results)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/import",
@@ -152,7 +150,6 @@ async def ws_import_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/save",
@@ -209,7 +206,6 @@ async def ws_save_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/delete",
@@ -237,7 +233,6 @@ async def ws_delete_blueprint(
)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "blueprint/substitute",

View File

@@ -11,7 +11,6 @@ from homeassistant.const import CONF_HOST, CONF_MAC, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import CONF_USE_SSL
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
PLATFORMS: Final[list[Platform]] = [
@@ -27,12 +26,11 @@ async def async_setup_entry(
"""Set up a config entry."""
host = config_entry.data[CONF_HOST]
mac = config_entry.data[CONF_MAC]
ssl = config_entry.data.get(CONF_USE_SSL, False)
session = async_create_clientsession(
hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False)
)
client = BraviaClient(host, mac, session=session, ssl=ssl)
client = BraviaClient(host, mac, session=session)
coordinator = BraviaTVCoordinator(
hass=hass,
config_entry=config_entry,

View File

@@ -28,7 +28,6 @@ from .const import (
ATTR_MODEL,
CONF_NICKNAME,
CONF_USE_PSK,
CONF_USE_SSL,
DOMAIN,
NICKNAME_PREFIX,
)
@@ -47,12 +46,11 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
def create_client(self) -> None:
"""Create Bravia TV client from config."""
host = self.device_config[CONF_HOST]
ssl = self.device_config[CONF_USE_SSL]
session = async_create_clientsession(
self.hass,
cookie_jar=CookieJar(unsafe=True, quote_cookie=False),
)
self.client = BraviaClient(host=host, session=session, ssl=ssl)
self.client = BraviaClient(host=host, session=session)
async def gen_instance_ids(self) -> tuple[str, str]:
"""Generate client_id and nickname."""
@@ -125,10 +123,10 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle authorize step."""
self.create_client()
if user_input is not None:
self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK]
self.device_config[CONF_USE_SSL] = user_input[CONF_USE_SSL]
self.create_client()
if user_input[CONF_USE_PSK]:
return await self.async_step_psk()
return await self.async_step_pin()
@@ -138,7 +136,6 @@ class BraviaTVConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_USE_PSK, default=False): bool,
vol.Required(CONF_USE_SSL, default=False): bool,
}
),
)

View File

@@ -12,7 +12,6 @@ ATTR_MODEL: Final = "model"
CONF_NICKNAME: Final = "nickname"
CONF_USE_PSK: Final = "use_psk"
CONF_USE_SSL: Final = "use_ssl"
DOMAIN: Final = "braviatv"
LEGACY_CLIENT_ID: Final = "HomeAssistant"

View File

@@ -22,7 +22,7 @@ from homeassistant.components.media_player import MediaType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CLIENT_ID, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -56,31 +56,8 @@ def catch_braviatv_errors[_BraviaTVCoordinatorT: BraviaTVCoordinator, **_P](
"""Catch Bravia errors and log message."""
try:
await func(self, *args, **kwargs)
except BraviaNotFound as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error_offline",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
except BraviaError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
_LOGGER.error("Command error: %s", err)
await self.async_request_refresh()
return wrapper
@@ -188,35 +165,17 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]):
if self.skipped_updates < 10:
self.connected = False
self.skipped_updates += 1
_LOGGER.debug(
"Update for %s skipped: the Bravia API service is reloading",
self.config_entry.title,
)
_LOGGER.debug("Update skipped, Bravia API service is reloading")
return
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error_not_found",
translation_placeholders={
"device": self.config_entry.title,
},
) from err
raise UpdateFailed("Error communicating with device") from err
except (BraviaConnectionError, BraviaConnectionTimeout, BraviaTurnedOff):
self.is_on = False
self.connected = False
_LOGGER.debug(
"Update for %s skipped: the TV is turned off", self.config_entry.title
)
_LOGGER.debug("Update skipped, Bravia TV is off")
except BraviaError as err:
self.is_on = False
self.connected = False
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={
"device": self.config_entry.title,
"error": repr(err),
},
) from err
raise UpdateFailed("Error communicating with device") from err
async def async_update_volume(self) -> None:
"""Update volume information."""

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pybravia"],
"requirements": ["pybravia==0.4.1"],
"requirements": ["pybravia==0.3.4"],
"ssdp": [
{
"manufacturer": "Sony Corporation",

View File

@@ -15,10 +15,9 @@
"step": {
"authorize": {
"data": {
"use_psk": "Use PSK authentication",
"use_ssl": "Use SSL connection"
"use_psk": "Use PSK authentication"
},
"description": "Make sure that «Control remotely» is enabled on your TV. Go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended, as it is more stable. \n\nUse an SSL connection only if your TV supports this connection type.",
"description": "Make sure that «Control remotely» is enabled on your TV, go to: \nSettings -> Network -> Remote device settings -> Control remotely. \n\nThere are two authorization methods: PIN code or PSK (Pre-Shared Key). \nAuthorization via PSK is recommended as more stable.",
"title": "Authorize Sony Bravia TV"
},
"confirm": {
@@ -55,22 +54,5 @@
"name": "Terminate apps"
}
}
},
"exceptions": {
"command_error": {
"message": "Error sending command to {device}: {error}"
},
"command_error_not_found": {
"message": "Error sending command to {device}: the Bravia API service is reloading"
},
"command_error_offline": {
"message": "Error sending command to {device}: the TV is turned off"
},
"update_error": {
"message": "Error updating data for {device}: {error}"
},
"update_error_not_found": {
"message": "Error updating data for {device}: the Bravia API service is stuck"
}
}
}

View File

@@ -1,6 +1,5 @@
"""The BSB-Lan integration."""
import asyncio
import dataclasses
from bsblan import (
@@ -78,16 +77,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
bsblan = BSBLAN(config, session)
try:
# Initialize the client first - this sets up internal caches and validates
# the connection by fetching firmware version
# Initialize the client first - this sets up internal caches and validates the connection
await bsblan.initialize()
# Fetch device metadata in parallel for faster startup
device, info, static = await asyncio.gather(
bsblan.device(),
bsblan.info(),
bsblan.static_values(),
)
# Fetch all required device metadata
device = await bsblan.device()
info = await bsblan.info()
static = await bsblan.static_values()
except BSBLANConnectionError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@@ -115,10 +110,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
# Perform first refresh of fast coordinator (required for entities)
# Perform first refresh of both coordinators
await fast_coordinator.async_config_entry_first_refresh()
# Refresh slow coordinator - don't fail if DHW is not available
# Try to refresh slow coordinator, but don't fail if DHW is not available
# This allows the integration to work even if the device doesn't support DHW
await slow_coordinator.async_refresh()

View File

@@ -111,17 +111,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
return None
return self.coordinator.data.state.target_temperature.value
@property
def _hvac_mode_value(self) -> int | str | None:
"""Return the raw hvac_mode value from the coordinator."""
if (hvac_mode := self.coordinator.data.state.hvac_mode) is None:
return None
return hvac_mode.value
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
if (hvac_mode_value := self._hvac_mode_value) is None:
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
if hvac_mode_value is None:
return None
# BSB-Lan returns integer values: 0=off, 1=auto, 2=eco, 3=heat
if isinstance(hvac_mode_value, int):
@@ -131,8 +125,9 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity):
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
hvac_mode_value = self.coordinator.data.state.hvac_mode.value
# BSB-Lan mode 2 is eco/reduced mode
if self._hvac_mode_value == 2:
if hvac_mode_value == 2:
return PRESET_ECO
return PRESET_NONE

View File

@@ -2,6 +2,7 @@
from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import (
BSBLAN,
@@ -22,17 +23,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
# Filter lists for optimized API calls - only fetch parameters we actually use
# This significantly reduces response time (~0.2s per parameter saved)
STATE_INCLUDE = ["current_temperature", "target_temperature", "hvac_mode"]
SENSOR_INCLUDE = ["current_temperature", "outside_temperature"]
DHW_STATE_INCLUDE = [
"operating_mode",
"nominal_setpoint",
"dhw_actual_value_top_temperature",
]
DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"]
@dataclass
class BSBLanFastData:
@@ -90,18 +80,26 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
config_entry,
client,
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
update_interval=SCAN_INTERVAL_FAST,
update_interval=self._get_update_interval(),
)
def _get_update_interval(self) -> timedelta:
"""Get the update interval with a random offset.
Add a random number of seconds to avoid timeouts when
the BSB-Lan device is already/still busy retrieving data,
e.g. for MQTT or internal logging.
"""
return SCAN_INTERVAL_FAST + timedelta(seconds=randint(1, 8))
async def _async_update_data(self) -> BSBLanFastData:
"""Fetch fast-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
# This reduces response time significantly (~0.2s per parameter)
state = await self.client.state(include=STATE_INCLUDE)
sensor = await self.client.sensor(include=SENSOR_INCLUDE)
dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE)
# Fetch fast-changing data (state, sensor, DHW state)
state = await self.client.state()
sensor = await self.client.sensor()
dhw = await self.client.hot_water_state()
except BSBLANAuthError as err:
raise ConfigEntryAuthFailed(
@@ -113,6 +111,9 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
f"Error while establishing connection with BSB-Lan device at {host}"
) from err
# Update the interval with random jitter for next update
self.update_interval = self._get_update_interval()
return BSBLanFastData(
state=state,
sensor=sensor,
@@ -142,8 +143,8 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
"""Fetch slow-changing data from the BSB-Lan device."""
try:
# Client is already initialized in async_setup_entry
# Use include filtering to only fetch parameters we actually use
dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE)
# Fetch slow-changing configuration data
dhw_config = await self.client.hot_water_config()
dhw_schedule = await self.client.hot_water_schedule()
except AttributeError:

View File

@@ -29,11 +29,7 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=(
data.info.device_identification.value
if data.info.device_identification
else None
),
model=data.info.device_identification.value,
sw_version=data.device.version,
configuration_url=f"http://{host}",
)

View File

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

View File

@@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.16.0"]
"requirements": ["bthome-ble==3.17.0"]
}

View File

@@ -15,13 +15,5 @@
"get_events": {
"service": "mdi:calendar-month"
}
},
"triggers": {
"event_ended": {
"trigger": "mdi:calendar-end"
},
"event_started": {
"trigger": "mdi:calendar-start"
}
}
}

View File

@@ -45,14 +45,6 @@
"title": "Detected use of deprecated action calendar.list_events"
}
},
"selector": {
"trigger_offset_type": {
"options": {
"after": "After",
"before": "Before"
}
}
},
"services": {
"create_event": {
"description": "Adds a new calendar event.",
@@ -111,35 +103,5 @@
"name": "Get events"
}
},
"title": "Calendar",
"triggers": {
"event_ended": {
"description": "Triggers when a calendar event ends.",
"fields": {
"offset": {
"description": "Offset from the end of the event.",
"name": "Offset"
},
"offset_type": {
"description": "Whether to trigger before or after the end of the event, if an offset is defined.",
"name": "Offset type"
}
},
"name": "Calendar event ended"
},
"event_started": {
"description": "Triggers when a calendar event starts.",
"fields": {
"offset": {
"description": "Offset from the start of the event.",
"name": "Offset"
},
"offset_type": {
"description": "Whether to trigger before or after the start of the event, if an offset is defined.",
"name": "Offset type"
}
},
"name": "Calendar event started"
}
}
"title": "Calendar"
}

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
@@ -11,15 +10,8 @@ from typing import TYPE_CHECKING, Any, cast
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ENTITY_ID,
CONF_EVENT,
CONF_OFFSET,
CONF_OPTIONS,
CONF_TARGET,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
@@ -28,13 +20,12 @@ from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_time_interval,
)
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import CalendarEntity, CalendarEvent
from .const import DATA_COMPONENT, DOMAIN
from .const import DATA_COMPONENT
_LOGGER = logging.getLogger(__name__)
@@ -42,35 +33,19 @@ EVENT_START = "start"
EVENT_END = "end"
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
CONF_OFFSET_TYPE = "offset_type"
OFFSET_TYPE_BEFORE = "before"
OFFSET_TYPE_AFTER = "after"
_SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA = {
_OPTIONS_SCHEMA_DICT = {
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
_SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA = vol.Schema(
_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS): _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA,
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
},
)
_EVENT_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS, default={}): {
vol.Required(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
vol.Required(CONF_OFFSET_TYPE, default=OFFSET_TYPE_BEFORE): vol.In(
{OFFSET_TYPE_BEFORE, OFFSET_TYPE_AFTER}
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
# mypy: disallow-any-generics
@@ -80,7 +55,6 @@ class QueuedCalendarEvent:
trigger_time: datetime.datetime
event: CalendarEvent
entity_id: str
@dataclass
@@ -120,7 +94,7 @@ class Timespan:
return f"[{self.start}, {self.end})"
type EventFetcher = Callable[[Timespan], Awaitable[list[tuple[str, CalendarEvent]]]]
type EventFetcher = Callable[[Timespan], Awaitable[list[CalendarEvent]]]
type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEvent]]]
@@ -136,24 +110,15 @@ def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity:
return entity
def event_fetcher(hass: HomeAssistant, entity_ids: set[str]) -> EventFetcher:
def event_fetcher(hass: HomeAssistant, entity_id: str) -> EventFetcher:
"""Build an async_get_events wrapper to fetch events during a time span."""
async def async_get_events(timespan: Timespan) -> list[tuple[str, CalendarEvent]]:
async def async_get_events(timespan: Timespan) -> list[CalendarEvent]:
"""Return events active in the specified time span."""
entity = get_entity(hass, entity_id)
# Expand by one second to make the end time exclusive
end_time = timespan.end + datetime.timedelta(seconds=1)
events: list[tuple[str, CalendarEvent]] = []
for entity_id in entity_ids:
entity = get_entity(hass, entity_id)
events.extend(
(entity_id, event)
for event in await entity.async_get_events(
hass, timespan.start, end_time
)
)
return events
return await entity.async_get_events(hass, timespan.start, end_time)
return async_get_events
@@ -177,11 +142,12 @@ def queued_event_fetcher(
# Example: For an EVENT_END trigger the event may start during this
# time span, but need to be triggered later when the end happens.
results = []
for entity_id, event in active_events:
trigger_time = get_trigger_time(event)
for trigger_time, event in zip(
map(get_trigger_time, active_events), active_events, strict=False
):
if trigger_time not in offset_timespan:
continue
results.append(QueuedCalendarEvent(trigger_time + offset, event, entity_id))
results.append(QueuedCalendarEvent(trigger_time + offset, event))
_LOGGER.debug(
"Scan events @ %s%s found %s eligible of %s active",
@@ -274,7 +240,6 @@ class CalendarEventListener:
_LOGGER.debug("Dispatching event: %s", queued_event.event)
payload = {
**self._trigger_payload,
ATTR_ENTITY_ID: queued_event.entity_id,
"calendar_event": queued_event.event.as_dict(),
}
self._action_runner(payload, "calendar event state change")
@@ -295,77 +260,8 @@ class CalendarEventListener:
self._listen_next_calendar_event()
class TargetCalendarEventListener(TargetEntityChangeTracker):
"""Helper class to listen to calendar events for target entity changes."""
def __init__(
self,
hass: HomeAssistant,
target_selection: TargetSelection,
event_type: str,
offset: datetime.timedelta,
run_action: TriggerActionRunner,
) -> None:
"""Initialize the state change tracker."""
def entity_filter(entities: set[str]) -> set[str]:
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
super().__init__(hass, target_selection, entity_filter)
self._event_type = event_type
self._offset = offset
self._run_action = run_action
self._trigger_data = {
"event": event_type,
"offset": offset,
}
self._pending_listener_task: asyncio.Task[None] | None = None
self._calendar_event_listener: CalendarEventListener | None = None
@callback
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
"""Restart the listeners when the list of entities of the tracked targets is updated."""
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = self._hass.async_create_task(
self._start_listening(tracked_entities)
)
async def _start_listening(self, tracked_entities: set[str]) -> None:
"""Start listening for calendar events."""
_LOGGER.debug("Tracking events for calendars: %s", tracked_entities)
if self._calendar_event_listener:
self._calendar_event_listener.async_detach()
self._calendar_event_listener = CalendarEventListener(
self._hass,
self._run_action,
self._trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, tracked_entities),
self._event_type,
self._offset,
),
)
await self._calendar_event_listener.async_attach()
def _unsubscribe(self) -> None:
"""Unsubscribe from all events."""
super()._unsubscribe()
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = None
if self._calendar_event_listener:
self._calendar_event_listener.async_detach()
self._calendar_event_listener = None
class SingleEntityEventTrigger(Trigger):
"""Legacy single calendar entity event trigger."""
class EventTrigger(Trigger):
"""Calendar event trigger."""
_options: dict[str, Any]
@@ -375,7 +271,7 @@ class SingleEntityEventTrigger(Trigger):
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _SINGLE_ENTITY_EVENT_OPTIONS_SCHEMA
complete_config, _OPTIONS_SCHEMA_DICT
)
return await super().async_validate_complete_config(hass, complete_config)
@@ -384,7 +280,7 @@ class SingleEntityEventTrigger(Trigger):
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _SINGLE_ENTITY_EVENT_TRIGGER_SCHEMA(config))
return cast(ConfigType, _CONFIG_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
@@ -415,72 +311,15 @@ class SingleEntityEventTrigger(Trigger):
run_action,
trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, {entity_id}), event_type, offset
event_fetcher(self._hass, entity_id), event_type, offset
),
)
await listener.async_attach()
return listener.async_detach
class EventTrigger(Trigger):
"""Calendar event trigger."""
_options: dict[str, Any]
_event_type: str
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _EVENT_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
assert config.options is not None
self._target = config.target
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
offset = self._options[CONF_OFFSET]
offset_type = self._options[CONF_OFFSET_TYPE]
if offset_type == OFFSET_TYPE_BEFORE:
offset = -offset
target_selection = TargetSelection(self._target)
if not target_selection.has_any_target:
raise HomeAssistantError(f"No target defined in {self._target}")
listener = TargetCalendarEventListener(
self._hass, target_selection, self._event_type, offset, run_action
)
return listener.async_setup()
class EventStartedTrigger(EventTrigger):
"""Calendar event started trigger."""
_event_type = EVENT_START
class EventEndedTrigger(EventTrigger):
"""Calendar event ended trigger."""
_event_type = EVENT_END
TRIGGERS: dict[str, type[Trigger]] = {
"_": SingleEntityEventTrigger,
"event_started": EventStartedTrigger,
"event_ended": EventEndedTrigger,
"_": EventTrigger,
}

View File

@@ -1,27 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: calendar
fields:
offset:
required: true
default:
days: 0
hours: 0
minutes: 0
seconds: 0
selector:
duration:
enable_day: true
offset_type:
required: true
default: before
selector:
select:
translation_key: trigger_offset_type
options:
- before
- after
event_started: *trigger_common
event_ended: *trigger_common

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from webexpythonsdk import ApiError, WebexAPI, exceptions
@@ -52,7 +51,7 @@ class CiscoWebexNotificationService(BaseNotificationService):
self.room = room
self.client = client
def send_message(self, message: str = "", **kwargs: Any) -> None:
def send_message(self, message="", **kwargs):
"""Send a message to a user."""
title = ""

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from http import HTTPStatus
import json
import logging
from typing import Any
import requests
import voluptuous as vol
@@ -82,7 +81,7 @@ class ClicksendNotificationService(BaseNotificationService):
self.language = config[CONF_LANGUAGE]
self.voice = config[CONF_VOICE]
def send_message(self, message: str = "", **kwargs: Any) -> None:
def send_message(self, message="", **kwargs):
"""Send a voice call to a user."""
data = {
"messages": [

View File

@@ -33,7 +33,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [vol.Coerce(HVACMode)]
cv.ensure_list, vol.Length(min=1), [HVACMode]
),
},
}

View File

@@ -19,10 +19,6 @@
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
@@ -31,11 +27,14 @@
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:

View File

@@ -50,6 +50,7 @@ from . import (
from .client import CloudClient
from .const import (
CONF_ACCOUNT_LINK_SERVER,
CONF_ACCOUNTS_SERVER,
CONF_ACME_SERVER,
CONF_ALEXA,
CONF_ALIASES,
@@ -137,6 +138,7 @@ _BASE_CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ACCOUNT_LINK_SERVER): str,
vol.Optional(CONF_ACCOUNTS_SERVER): str,
vol.Optional(CONF_ACME_SERVER): str,
vol.Optional(CONF_API_SERVER): str,
vol.Optional(CONF_RELAYER_SERVER): str,

View File

@@ -76,6 +76,7 @@ CONF_GOOGLE_ACTIONS = "google_actions"
CONF_USER_POOL_ID = "user_pool_id"
CONF_ACCOUNT_LINK_SERVER = "account_link_server"
CONF_ACCOUNTS_SERVER = "accounts_server"
CONF_ACME_SERVER = "acme_server"
CONF_API_SERVER = "api_server"
CONF_DISCOVERY_SERVICE_ACTIONS = "discovery_service_actions"

View File

@@ -19,7 +19,6 @@ import attr
from hass_nabucasa import AlreadyConnectedError, Cloud, auth
from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice_data import TTS_VOICES
import psutil_home_assistant as ha_psutil
import voluptuous as vol
from homeassistant.components import websocket_api
@@ -28,7 +27,6 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
)
from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.hassio import get_addons_stats, get_supervisor_info
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
@@ -39,7 +37,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.hassio import is_hassio
from homeassistant.loader import (
async_get_custom_components,
async_get_loaded_integration,
@@ -574,11 +571,6 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n"
)
markdown += await self._get_host_resources_markdown(hass)
if is_hassio(hass):
markdown += await self._get_addon_resources_markdown(hass)
log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
logs = "\n".join(await log_handler.get_logs(hass))
markdown += (
@@ -592,103 +584,6 @@ class DownloadSupportPackageView(HomeAssistantView):
return markdown
async def _get_host_resources_markdown(self, hass: HomeAssistant) -> str:
"""Get host resource usage markdown using psutil."""
def _collect_system_stats() -> dict[str, Any]:
"""Collect system stats."""
psutil_wrapper = ha_psutil.PsutilWrapper()
psutil_mod = psutil_wrapper.psutil
cpu_percent = psutil_mod.cpu_percent(interval=0.1)
memory = psutil_mod.virtual_memory()
disk = psutil_mod.disk_usage("/")
return {
"cpu_percent": cpu_percent,
"memory_total": memory.total,
"memory_used": memory.used,
"memory_available": memory.available,
"memory_percent": memory.percent,
"disk_total": disk.total,
"disk_used": disk.used,
"disk_free": disk.free,
"disk_percent": disk.percent,
}
markdown = ""
try:
stats = await hass.async_add_executor_job(_collect_system_stats)
markdown += "## Host resource usage\n\n"
markdown += "Resource | Value\n"
markdown += "--- | ---\n"
markdown += f"CPU usage | {stats['cpu_percent']}%\n"
memory_total_gb = round(stats["memory_total"] / (1024**3), 2)
memory_used_gb = round(stats["memory_used"] / (1024**3), 2)
memory_available_gb = round(stats["memory_available"] / (1024**3), 2)
markdown += f"Memory total | {memory_total_gb} GB\n"
markdown += (
f"Memory used | {memory_used_gb} GB ({stats['memory_percent']}%)\n"
)
markdown += f"Memory available | {memory_available_gb} GB\n"
disk_total_gb = round(stats["disk_total"] / (1024**3), 2)
disk_used_gb = round(stats["disk_used"] / (1024**3), 2)
disk_free_gb = round(stats["disk_free"] / (1024**3), 2)
markdown += f"Disk total | {disk_total_gb} GB\n"
markdown += f"Disk used | {disk_used_gb} GB ({stats['disk_percent']}%)\n"
markdown += f"Disk free | {disk_free_gb} GB\n"
markdown += "\n"
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Host resource usage\n\n"
markdown += "Unable to collect host resource information\n\n"
return markdown
async def _get_addon_resources_markdown(self, hass: HomeAssistant) -> str:
"""Get add-on resource usage markdown for hassio."""
markdown = ""
try:
supervisor_info = get_supervisor_info(hass) or {}
addons_stats = get_addons_stats(hass)
addons = supervisor_info.get("addons", [])
if addons:
markdown += "## Add-on resource usage\n\n"
markdown += "<details><summary>Add-on resources</summary>\n\n"
markdown += "Add-on | Version | State | CPU | Memory\n"
markdown += "--- | --- | --- | --- | ---\n"
for addon in addons:
slug = addon.get("slug", "unknown")
name = addon.get("name", slug)
version = addon.get("version", "unknown")
state = addon.get("state", "unknown")
addon_stats = addons_stats.get(slug, {})
cpu = addon_stats.get("cpu_percent")
memory = addon_stats.get("memory_percent")
cpu_str = f"{cpu}%" if cpu is not None else "N/A"
memory_str = f"{memory}%" if memory is not None else "N/A"
markdown += (
f"{name} | {version} | {state} | {cpu_str} | {memory_str}\n"
)
markdown += "\n</details>\n\n"
except Exception: # noqa: BLE001
# Broad exception catch for robustness in support package generation
markdown += "## Add-on resource usage\n\n"
markdown += "Unable to collect add-on resource information\n\n"
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""

View File

@@ -5,8 +5,7 @@
"alexa",
"assist_pipeline",
"backup",
"google_assistant",
"hassio"
"google_assistant"
],
"codeowners": ["@home-assistant/cloud"],
"dependencies": ["auth", "http", "repairs", "webhook", "web_rtc"],
@@ -14,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.9.0", "psutil-home-assistant==0.0.1"],
"requirements": ["hass-nabucasa==1.7.0"],
"single_config_entry": true
}

View File

@@ -119,7 +119,7 @@ class Concord232ZoneSensor(BinarySensorEntity):
self._zone_type = zone_type
@property
def device_class(self) -> BinarySensorDeviceClass:
def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type

View File

@@ -11,11 +11,13 @@ from homeassistant import config_entries
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.entity_component import async_get_entity_suggested_object_id
from homeassistant.helpers.json import json_dumps
_LOGGER = logging.getLogger(__name__)
@@ -349,12 +351,26 @@ def websocket_get_automatic_entity_ids(
if not (entry := registry.entities.get(entity_id)):
automatic_entity_ids[entity_id] = None
continue
new_entity_id = registry.async_regenerate_entity_id(
entry,
try:
suggested = async_get_entity_suggested_object_id(hass, entity_id)
except HomeAssistantError as err:
# This is raised if the entity has no object.
_LOGGER.debug(
"Unable to get suggested object ID for %s, entity ID: %s (%s)",
entry.entity_id,
entity_id,
err,
)
automatic_entity_ids[entity_id] = None
continue
suggested_entity_id = registry.async_generate_entity_id(
entry.domain,
suggested or f"{entry.platform}_{entry.unique_id}",
current_entity_id=entity_id,
reserved_entity_ids=reserved_entity_ids,
)
automatic_entity_ids[entity_id] = new_entity_id
reserved_entity_ids.add(new_entity_id)
automatic_entity_ids[entity_id] = suggested_entity_id
reserved_entity_ids.add(suggested_entity_id)
connection.send_message(
websocket_api.result_message(msg["id"], automatic_entity_ids)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.6"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
}

View File

@@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["datadog"],
"quality_scale": "legacy",
"requirements": ["datadog==0.52.0"]
}

View File

@@ -169,7 +169,6 @@ FRIENDS_OF_HUE_SWITCH = {
}
RODRET_REMOTE_MODEL = "RODRET Dimmer"
RODRET_REMOTE_MODEL_2 = "RODRET wireless dimmer"
RODRET_REMOTE = {
(CONF_SHORT_RELEASE, CONF_TURN_ON): {CONF_EVENT: 1002},
(CONF_LONG_PRESS, CONF_TURN_ON): {CONF_EVENT: 1001},
@@ -625,7 +624,6 @@ REMOTES = {
HUE_WALL_REMOTE_MODEL: HUE_WALL_REMOTE,
FRIENDS_OF_HUE_SWITCH_MODEL: FRIENDS_OF_HUE_SWITCH,
RODRET_REMOTE_MODEL: RODRET_REMOTE,
RODRET_REMOTE_MODEL_2: RODRET_REMOTE,
SOMRIG_REMOTE_MODEL: SOMRIG_REMOTE,
STYRBAR_REMOTE_MODEL: STYRBAR_REMOTE,
SYMFONISK_SOUND_CONTROLLER_MODEL: SYMFONISK_SOUND_CONTROLLER,

View File

@@ -28,11 +28,10 @@ async def async_setup_entry(
DemoHumidifier(
name="Humidifier",
mode=None,
target_humidity=65,
target_humidity=68,
current_humidity=45,
action=HumidifierAction.HUMIDIFYING,
device_class=HumidifierDeviceClass.HUMIDIFIER,
target_humidity_step=5,
),
DemoHumidifier(
name="Dehumidifier",
@@ -67,7 +66,6 @@ class DemoHumidifier(HumidifierEntity):
is_on: bool = True,
action: HumidifierAction | None = None,
device_class: HumidifierDeviceClass | None = None,
target_humidity_step: float | None = None,
) -> None:
"""Initialize the humidifier device."""
self._attr_name = name
@@ -81,7 +79,6 @@ class DemoHumidifier(HumidifierEntity):
self._attr_mode = mode
self._attr_available_modes = available_modes
self._attr_device_class = device_class
self._attr_target_humidity_step = target_humidity_step
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""

View File

@@ -1,7 +1,6 @@
"""Support for Digital Ocean."""
from __future__ import annotations
from datetime import timedelta
import logging
import digitalocean
@@ -13,12 +12,27 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import Throttle
from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES
_LOGGER = logging.getLogger(__name__)
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DATA_DIGITAL_OCEAN = "data_do"
DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR]
DOMAIN = "digital_ocean"
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -17,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
from . import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -66,7 +65,6 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
"""Representation of a Digital Ocean droplet sensor."""
_attr_attribution = ATTRIBUTION
_attr_device_class = BinarySensorDeviceClass.MOVING
def __init__(self, do, droplet_id):
"""Initialize a new Digital Ocean sensor."""
@@ -81,12 +79,17 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
return self.data.name
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if the binary sensor is on."""
return self.data.status == "active"
@property
def extra_state_attributes(self) -> dict[str, Any]:
def device_class(self):
"""Return the class of this sensor."""
return BinarySensorDeviceClass.MOVING
@property
def extra_state_attributes(self):
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -1,30 +0,0 @@
"""Support for Digital Ocean."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import DigitalOcean
ATTR_CREATED_AT = "created_at"
ATTR_DROPLET_ID = "droplet_id"
ATTR_DROPLET_NAME = "droplet_name"
ATTR_FEATURES = "features"
ATTR_IPV4_ADDRESS = "ipv4_address"
ATTR_IPV6_ADDRESS = "ipv6_address"
ATTR_MEMORY = "memory"
ATTR_REGION = "region"
ATTR_VCPUS = "vcpus"
ATTRIBUTION = "Data provided by Digital Ocean"
CONF_DROPLETS = "droplets"
DOMAIN = "digital_ocean"
DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)

View File

@@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
from . import (
ATTR_CREATED_AT,
ATTR_DROPLET_ID,
ATTR_DROPLET_NAME,
@@ -80,12 +80,12 @@ class DigitalOceanSwitch(SwitchEntity):
return self.data.name
@property
def is_on(self) -> bool:
def is_on(self):
"""Return true if switch is on."""
return self.data.status == "active"
@property
def extra_state_attributes(self) -> dict[str, Any]:
def extra_state_attributes(self):
"""Return the state attributes of the Digital Ocean droplet."""
return {
ATTR_CREATED_AT: self.data.created_at,

View File

@@ -9,7 +9,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.2", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.1", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

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

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/dnsip",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["aiodns==4.0.0"]
"requirements": ["aiodns==3.6.1"]
}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService
from homeassistant.core import HomeAssistant
@@ -30,7 +29,7 @@ class DovadoSMSNotificationService(BaseNotificationService):
"""Initialize the service."""
self._client = client
def send_message(self, message: str, **kwargs: Any) -> None:
def send_message(self, message, **kwargs):
"""Send SMS to the specified target phone number."""
if not (target := kwargs.get(ATTR_TARGET)):
_LOGGER.error("One target is required")

View File

@@ -1,4 +1,4 @@
"""Duck DNS integration."""
"""Integrate with DuckDNS."""
from __future__ import annotations

View File

@@ -2,12 +2,11 @@
from __future__ import annotations
from aiohttp import ClientError
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import ConfigEntrySelector
@@ -63,25 +62,9 @@ async def update_domain_service(call: ServiceCall) -> None:
session = async_get_clientsession(call.hass)
try:
if not await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={
CONF_DOMAIN: entry.data[CONF_DOMAIN],
},
)
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={
CONF_DOMAIN: entry.data[CONF_DOMAIN],
},
) from e
await update_duckdns(
session,
entry.data[CONF_DOMAIN],
entry.data[CONF_ACCESS_TOKEN],
txt=call.data.get(ATTR_TXT),
)

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["easyenergy==2.2.0"],
"requirements": ["easyenergy==2.1.2"],
"single_config_entry": true
}

View File

@@ -1,7 +1,6 @@
"""Support for Ebusd daemon for communication with eBUS heating systems."""
import logging
from typing import Any
import ebusdpy
import voluptuous as vol
@@ -18,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
from .const import DOMAIN, SENSOR_TYPES
_LOGGER = logging.getLogger(__name__)
@@ -29,9 +28,9 @@ CACHE_TTL = 900
SERVICE_EBUSD_WRITE = "ebusd_write"
def verify_ebusd_config(config: ConfigType) -> ConfigType:
def verify_ebusd_config(config):
"""Verify eBusd config."""
circuit: str = config[CONF_CIRCUIT]
circuit = config[CONF_CIRCUIT]
for condition in config[CONF_MONITORED_CONDITIONS]:
if condition not in SENSOR_TYPES[circuit]:
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
@@ -60,17 +59,17 @@ CONFIG_SCHEMA = vol.Schema(
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the eBusd component."""
_LOGGER.debug("Integration setup started")
conf: ConfigType = config[DOMAIN]
name: str = conf[CONF_NAME]
circuit: str = conf[CONF_CIRCUIT]
monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
conf = config[DOMAIN]
name = conf[CONF_NAME]
circuit = conf[CONF_CIRCUIT]
monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
try:
ebusdpy.init(server_address)
except (TimeoutError, OSError):
return False
hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
hass.data[DOMAIN] = EbusdData(server_address, circuit)
sensor_config = {
CONF_MONITORED_CONDITIONS: monitored_conditions,
"client_name": name,
@@ -78,7 +77,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
}
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
_LOGGER.debug("Ebusd integration setup completed")
return True
@@ -87,13 +86,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
class EbusdData:
"""Get the latest data from Ebusd."""
def __init__(self, address: tuple[str, int], circuit: str) -> None:
def __init__(self, address, circuit):
"""Initialize the data object."""
self._circuit = circuit
self._address = address
self.value: dict[str, Any] = {}
self.value = {}
def update(self, name: str, stype: int) -> None:
def update(self, name, stype):
"""Call the Ebusd API to update the data."""
try:
_LOGGER.debug("Opening socket to ebusd %s", name)

View File

@@ -1,9 +1,5 @@
"""Constants for ebus component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.const import (
PERCENTAGE,
@@ -12,283 +8,277 @@ from homeassistant.const import (
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import EbusdData
DOMAIN = "ebusd"
EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
# SensorTypes from ebusdpy module :
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
SENSOR_TYPES = {
"700": {
"ActualFlowTemperatureDesired": (
"ActualFlowTemperatureDesired": [
"Hc1ActualFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"MaxFlowTemperatureDesired": (
],
"MaxFlowTemperatureDesired": [
"Hc1MaxFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"MinFlowTemperatureDesired": (
],
"MinFlowTemperatureDesired": [
"Hc1MinFlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
"HCSummerTemperatureLimit": (
],
"PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
"HCSummerTemperatureLimit": [
"Hc1SummerTempLimit",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
),
"HolidayTemperature": (
],
"HolidayTemperature": [
"HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWTemperatureDesired": (
],
"HWTemperatureDesired": [
"HwcTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWActualTemperature": (
],
"HWActualTemperature": [
"HwcStorageTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
"HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
"HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
"HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
"HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
"HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
"HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
"HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
"WaterPressure": (
],
"HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
"HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
"HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
"HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
"HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
"HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
"HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
"HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
"WaterPressure": [
"WaterPressure",
UnitOfPressure.BAR,
"mdi:water-pump",
0,
SensorDeviceClass.PRESSURE,
),
"Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
"Zone1NightTemperature": (
],
"Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
"Zone1NightTemperature": [
"z1NightTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-night",
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1DayTemperature": (
],
"Zone1DayTemperature": [
"z1DayTemp",
UnitOfTemperature.CELSIUS,
"mdi:weather-sunny",
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1HolidayTemperature": (
],
"Zone1HolidayTemperature": [
"z1HolidayTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1RoomTemperature": (
],
"Zone1RoomTemperature": [
"z1RoomTemp",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1ActualRoomTemperatureDesired": (
],
"Zone1ActualRoomTemperatureDesired": [
"z1ActualRoomTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
"Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
"Zone1TimerWednesday": (
],
"Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
"Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
"Zone1TimerWednesday": [
"z1Timer.Wednesday",
None,
"mdi:timer-outline",
1,
None,
),
"Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
"Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
"Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
"Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
"ContinuosHeating": (
],
"Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
"Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
"Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
"Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
"ContinuosHeating": [
"ContinuosHeating",
UnitOfTemperature.CELSIUS,
"mdi:weather-snowy",
0,
SensorDeviceClass.TEMPERATURE,
),
"PowerEnergyConsumptionLastMonth": (
],
"PowerEnergyConsumptionLastMonth": [
"PrEnergySumHcLastMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"PowerEnergyConsumptionThisMonth": (
],
"PowerEnergyConsumptionThisMonth": [
"PrEnergySumHcThisMonth",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
],
},
"ehp": {
"HWTemperature": (
"HWTemperature": [
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"OutsideTemp": (
],
"OutsideTemp": [
"OutsideTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
],
},
"bai": {
"HotWaterTemperature": (
"HotWaterTemperature": [
"HwcTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"StorageTemperature": (
],
"StorageTemperature": [
"StorageTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"DesiredStorageTemperature": (
],
"DesiredStorageTemperature": [
"StorageTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"OutdoorsTemperature": (
],
"OutdoorsTemperature": [
"OutdoorstempSensor",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"WaterPressure": (
],
"WaterPressure": [
"WaterPressure",
UnitOfPressure.BAR,
"mdi:pipe",
4,
SensorDeviceClass.PRESSURE,
),
"AverageIgnitionTime": (
],
"AverageIgnitionTime": [
"averageIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"MaximumIgnitionTime": (
],
"MaximumIgnitionTime": [
"maxIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"MinimumIgnitionTime": (
],
"MinimumIgnitionTime": [
"minIgnitiontime",
UnitOfTime.SECONDS,
"mdi:av-timer",
0,
SensorDeviceClass.DURATION,
),
"ReturnTemperature": (
],
"ReturnTemperature": [
"ReturnTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
"HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
"DesiredFlowTemperature": (
],
"CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
"HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
"DesiredFlowTemperature": [
"FlowTempDesired",
UnitOfTemperature.CELSIUS,
None,
0,
SensorDeviceClass.TEMPERATURE,
),
"FlowTemperature": (
],
"FlowTemperature": [
"FlowTemp",
UnitOfTemperature.CELSIUS,
None,
4,
SensorDeviceClass.TEMPERATURE,
),
"Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
"PowerEnergyConsumptionHeatingCircuit": (
],
"Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
"PowerEnergyConsumptionHeatingCircuit": [
"PrEnergySumHc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"PowerEnergyConsumptionHotWaterCircuit": (
],
"PowerEnergyConsumptionHotWaterCircuit": [
"PrEnergySumHwc1",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
"HeatingPartLoad": (
],
"RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
"HeatingPartLoad": [
"PartloadHcKW",
UnitOfEnergy.KILO_WATT_HOUR,
"mdi:flash",
0,
SensorDeviceClass.ENERGY,
),
"StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
"ModulationPercentage": (
],
"StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
"ModulationPercentage": [
"ModulationTempDesired",
PERCENTAGE,
"mdi:percent",
0,
None,
),
],
},
}

View File

@@ -4,16 +4,14 @@ from __future__ import annotations
import datetime
import logging
from typing import Any, cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import Throttle, dt as dt_util
from . import EbusdData
from .const import EBUSD_DATA, SensorSpecs
from .const import DOMAIN
TIME_FRAME1_BEGIN = "time_frame1_begin"
TIME_FRAME1_END = "time_frame1_end"
@@ -35,9 +33,9 @@ def setup_platform(
"""Set up the Ebus sensor."""
if not discovery_info:
return
ebusd_api = hass.data[EBUSD_DATA]
monitored_conditions: list[str] = discovery_info["monitored_conditions"]
name: str = discovery_info["client_name"]
ebusd_api = hass.data[DOMAIN]
monitored_conditions = discovery_info["monitored_conditions"]
name = discovery_info["client_name"]
add_entities(
(
@@ -51,8 +49,9 @@ def setup_platform(
class EbusdSensor(SensorEntity):
"""Ebusd component sensor methods definition."""
def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
def __init__(self, data, sensor, name):
"""Initialize the sensor."""
self._state = None
self._client_name = name
(
self._name,
@@ -64,15 +63,20 @@ class EbusdSensor(SensorEntity):
self.data = data
@property
def name(self) -> str:
def name(self):
"""Return the name of the sensor."""
return f"{self._client_name} {self._name}"
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
def native_value(self):
"""Return the state of the sensor."""
return self._state
@property
def extra_state_attributes(self):
"""Return the device state attributes."""
if self._type == 1 and (native_value := self.native_value) is not None:
schedule: dict[str, str | None] = {
if self._type == 1 and self._state is not None:
schedule = {
TIME_FRAME1_BEGIN: None,
TIME_FRAME1_END: None,
TIME_FRAME2_BEGIN: None,
@@ -80,7 +84,7 @@ class EbusdSensor(SensorEntity):
TIME_FRAME3_BEGIN: None,
TIME_FRAME3_END: None,
}
time_frame = cast(str, native_value).split(";")
time_frame = self._state.split(";")
for index, item in enumerate(sorted(schedule.items())):
if index < len(time_frame):
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
@@ -92,17 +96,17 @@ class EbusdSensor(SensorEntity):
return None
@property
def device_class(self) -> SensorDeviceClass | None:
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._device_class
@property
def icon(self) -> str | None:
def icon(self):
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self) -> str | None:
def native_unit_of_measurement(self):
"""Return the unit of measurement."""
return self._unit_of_measurement
@@ -114,6 +118,6 @@ class EbusdSensor(SensorEntity):
if self._name not in self.data.value:
return
self._attr_native_value = self.data.value[self._name]
self._state = self.data.value[self._name]
except RuntimeError:
_LOGGER.debug("EbusdData.update exception")

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.1"]
"requirements": ["py-sucks==0.9.11", "deebot-client==17.0.0"]
}

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import dataclasses
from datetime import datetime
import logging
from typing import Final
from aioecowitt import EcoWittSensor, EcoWittSensorTypes
@@ -40,9 +39,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
from . import EcowittConfigEntry
from .entity import EcowittEntity
_LOGGER = logging.getLogger(__name__)
_METRIC: Final = (
EcoWittSensorTypes.TEMPERATURE_C,
EcoWittSensorTypes.RAIN_COUNT_MM,
@@ -61,40 +57,6 @@ _IMPERIAL: Final = (
)
_RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING: Final = {
"eventrainin": SensorStateClass.TOTAL_INCREASING,
"hourlyrainin": None,
"totalrainin": SensorStateClass.TOTAL_INCREASING,
"dailyrainin": SensorStateClass.TOTAL_INCREASING,
"weeklyrainin": SensorStateClass.TOTAL_INCREASING,
"monthlyrainin": SensorStateClass.TOTAL_INCREASING,
"yearlyrainin": SensorStateClass.TOTAL_INCREASING,
"last24hrainin": None,
"eventrainmm": SensorStateClass.TOTAL_INCREASING,
"hourlyrainmm": None,
"totalrainmm": SensorStateClass.TOTAL_INCREASING,
"dailyrainmm": SensorStateClass.TOTAL_INCREASING,
"weeklyrainmm": SensorStateClass.TOTAL_INCREASING,
"monthlyrainmm": SensorStateClass.TOTAL_INCREASING,
"yearlyrainmm": SensorStateClass.TOTAL_INCREASING,
"last24hrainmm": None,
"erain_piezo": SensorStateClass.TOTAL_INCREASING,
"hrain_piezo": None,
"drain_piezo": SensorStateClass.TOTAL_INCREASING,
"wrain_piezo": SensorStateClass.TOTAL_INCREASING,
"mrain_piezo": SensorStateClass.TOTAL_INCREASING,
"yrain_piezo": SensorStateClass.TOTAL_INCREASING,
"last24hrain_piezo": None,
"erain_piezomm": SensorStateClass.TOTAL_INCREASING,
"hrain_piezomm": None,
"drain_piezomm": SensorStateClass.TOTAL_INCREASING,
"wrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"mrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"yrain_piezomm": SensorStateClass.TOTAL_INCREASING,
"last24hrain_piezomm": None,
}
ECOWITT_SENSORS_MAPPING: Final = {
EcoWittSensorTypes.HUMIDITY: SensorEntityDescription(
key="HUMIDITY",
@@ -323,15 +285,15 @@ async def async_setup_entry(
name=sensor.name,
)
if sensor.stype in (
EcoWittSensorTypes.RAIN_COUNT_INCHES,
EcoWittSensorTypes.RAIN_COUNT_MM,
# Only total rain needs state class for long-term statistics
if sensor.key in (
"totalrainin",
"totalrainmm",
):
if sensor.key not in _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING:
_LOGGER.warning("Unknown rain count sensor: %s", sensor.key)
return
state_class = _RAIN_COUNT_SENSORS_STATE_CLASS_MAPPING[sensor.key]
description = dataclasses.replace(description, state_class=state_class)
description = dataclasses.replace(
description,
state_class=SensorStateClass.TOTAL_INCREASING,
)
async_add_entities([EcowittSensorEntity(sensor, description)])

View File

@@ -75,6 +75,6 @@ class EgardiaBinarySensor(BinarySensorEntity):
return self._state == STATE_ON
@property
def device_class(self) -> BinarySensorDeviceClass | None:
def device_class(self):
"""Return the device class."""
return self._device_class

View File

@@ -37,7 +37,7 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
name=device.name,
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
manufacturer="EHEIM",
model=device.model_name,
model=device.device_type.model_name,
identifiers={(DOMAIN, device.mac_address)},
suggested_area=device.aquarium_name,
sw_version=device.sw_version,
@@ -59,9 +59,9 @@ class EheimDigitalEntity[_DeviceT: EheimDigitalDevice](
def exception_handler[_EntityT: EheimDigitalEntity[EheimDigitalDevice], **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate eheimdigital calls to handle exceptions.
"""Decorate AirGradient calls to handle exceptions.
A decorator that wraps the passed in function, catches eheimdigital errors.
A decorator that wraps the passed in function, catches AirGradient errors.
"""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "platinum",
"requirements": ["eheimdigital==1.5.0"],
"requirements": ["eheimdigital==1.4.0"],
"zeroconf": [
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
]

View File

@@ -6,7 +6,6 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import HeaterUnit
@@ -22,7 +21,6 @@ from homeassistant.const import (
PRECISION_WHOLE,
EntityCategory,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -44,34 +42,6 @@ class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice](
uom_fn: Callable[[_DeviceT], str] | None = None
FILTER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalFilter], ...] = (
EheimDigitalNumberDescription[EheimDigitalFilter](
key="high_pulse_time",
translation_key="high_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.high_pulse_time,
set_value_fn=lambda device, value: device.set_high_pulse_time(int(value)),
),
EheimDigitalNumberDescription[EheimDigitalFilter](
key="low_pulse_time",
translation_key="low_pulse_time",
entity_category=EntityCategory.CONFIG,
native_step=PRECISION_WHOLE,
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=NumberDeviceClass.DURATION,
native_min_value=5,
native_max_value=200000,
value_fn=lambda device: device.low_pulse_time,
set_value_fn=lambda device, value: device.set_low_pulse_time(int(value)),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalNumberDescription[EheimDigitalClassicVario], ...
] = (
@@ -175,13 +145,6 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalNumber[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalHeater):
entities.extend(
EheimDigitalNumber[EheimDigitalHeater](

View File

@@ -2,19 +2,13 @@
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any, Literal, override
from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import (
FilterMode,
FilterModeProf,
UnitOfMeasurement as EheimDigitalUnitOfMeasurement,
)
from eheimdigital.types import FilterMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfFrequency, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -30,109 +24,8 @@ class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice](
):
"""Class describing EHEIM Digital select entities."""
options_fn: Callable[[_DeviceT], list[str]] | None = None
use_api_unit: Literal[True] | None = None
value_fn: Callable[[_DeviceT], str | None]
set_value_fn: Callable[[_DeviceT, str], Awaitable[None] | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSelectDescription[EheimDigitalFilter], ...] = (
EheimDigitalSelectDescription[EheimDigitalFilter](
key="filter_mode",
translation_key="filter_mode",
entity_category=EntityCategory.CONFIG,
options=[item.lower() for item in FilterModeProf._member_names_],
value_fn=lambda device: device.filter_mode.name.lower(),
set_value_fn=lambda device, value: device.set_filter_mode(
FilterModeProf[value.upper()]
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="manual_speed",
translation_key="manual_speed",
entity_category=EntityCategory.CONFIG,
unit_of_measurement=UnitOfFrequency.HERTZ,
options_fn=lambda device: [str(i) for i in device.filter_manual_values],
value_fn=lambda device: str(device.manual_speed),
set_value_fn=lambda device, value: device.set_manual_speed(float(value)),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="const_flow_speed",
translation_key="const_flow_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.const_flow]),
set_value_fn=(
lambda device, value: device.set_const_flow(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="day_speed",
translation_key="day_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(device.filter_const_flow_values[device.day_speed]),
set_value_fn=(
lambda device, value: device.set_day_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="night_speed",
translation_key="night_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.night_speed]
),
set_value_fn=(
lambda device, value: device.set_night_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="high_pulse_speed",
translation_key="high_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.high_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_high_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
EheimDigitalSelectDescription[EheimDigitalFilter](
key="low_pulse_speed",
translation_key="low_pulse_speed",
entity_category=EntityCategory.CONFIG,
use_api_unit=True,
unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_HOUR,
options_fn=lambda device: [str(i) for i in device.filter_const_flow_values],
value_fn=lambda device: str(
device.filter_const_flow_values[device.low_pulse_speed]
),
set_value_fn=(
lambda device, value: device.set_low_pulse_speed(
device.filter_const_flow_values.index(int(value))
)
),
),
)
set_value_fn: Callable[[_DeviceT, str], Awaitable[None]]
CLASSICVARIO_DESCRIPTIONS: tuple[
@@ -141,7 +34,11 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSelectDescription[EheimDigitalClassicVario](
key="filter_mode",
translation_key="filter_mode",
value_fn=lambda device: device.filter_mode.name.lower(),
value_fn=(
lambda device: device.filter_mode.name.lower()
if device.filter_mode is not None
else None
),
set_value_fn=(
lambda device, value: device.set_filter_mode(FilterMode[value.upper()])
),
@@ -171,11 +68,6 @@ async def async_setup_entry(
)
for description in CLASSICVARIO_DESCRIPTIONS
)
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalFilterSelect(coordinator, device, description)
for description in FILTER_DESCRIPTIONS
)
async_add_entities(entities)
@@ -190,8 +82,6 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
entity_description: EheimDigitalSelectDescription[_DeviceT]
_attr_options: list[str]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
@@ -201,49 +91,13 @@ class EheimDigitalSelect[_DeviceT: EheimDigitalDevice](
"""Initialize an EHEIM Digital select entity."""
super().__init__(coordinator, device)
self.entity_description = description
if description.options_fn is not None:
self._attr_options = description.options_fn(device)
elif description.options is not None:
self._attr_options = description.options
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
@exception_handler
async def async_select_option(self, option: str) -> None:
if await_return := self.entity_description.set_value_fn(self._device, option):
return await await_return
return None
return await self.entity_description.set_value_fn(self._device, option)
@override
def _async_update_attrs(self) -> None:
self._attr_current_option = self.entity_description.value_fn(self._device)
class EheimDigitalFilterSelect(EheimDigitalSelect[EheimDigitalFilter]):
"""Represent an EHEIM Digital Filter select entity."""
entity_description: EheimDigitalSelectDescription[EheimDigitalFilter]
_attr_native_unit_of_measurement: str | None
@override
def _async_update_attrs(self) -> None:
if (
self.entity_description.options is None
and self.entity_description.options_fn is not None
):
self._attr_options = self.entity_description.options_fn(self._device)
if self.entity_description.use_api_unit:
if (
self.entity_description.unit_of_measurement
== UnitOfVolumeFlowRate.LITERS_PER_HOUR
and self._device.usrdta["unit"]
== int(EheimDigitalUnitOfMeasurement.US_CUSTOMARY)
):
self._attr_native_unit_of_measurement = (
UnitOfVolumeFlowRate.GALLONS_PER_HOUR
)
else:
self._attr_native_unit_of_measurement = (
self.entity_description.unit_of_measurement
)
super()._async_update_attrs()

View File

@@ -6,7 +6,6 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import (
@@ -14,7 +13,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfFrequency, UnitOfTime
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -34,27 +33,6 @@ class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice](
value_fn: Callable[[_DeviceT], float | str | None]
FILTER_DESCRIPTIONS: tuple[EheimDigitalSensorDescription[EheimDigitalFilter], ...] = (
EheimDigitalSensorDescription[EheimDigitalFilter](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=1,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
EheimDigitalSensorDescription[EheimDigitalFilter](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
@@ -76,7 +54,11 @@ CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=lambda device: device.error_code.name.lower(),
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
@@ -98,13 +80,6 @@ async def async_setup_entry(
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities += [
EheimDigitalSensor[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
]
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](

View File

@@ -61,12 +61,6 @@
"day_speed": {
"name": "Day speed"
},
"high_pulse_time": {
"name": "High pulse duration"
},
"low_pulse_time": {
"name": "Low pulse duration"
},
"manual_speed": {
"name": "Manual speed"
},
@@ -84,32 +78,13 @@
}
},
"select": {
"const_flow_speed": {
"name": "Constant flow speed"
},
"day_speed": {
"name": "Day speed"
},
"filter_mode": {
"name": "Filter mode",
"state": {
"bio": "Bio",
"constant_flow": "Constant flow",
"manual": "Manual",
"pulse": "Pulse"
}
},
"high_pulse_speed": {
"name": "High pulse speed"
},
"low_pulse_speed": {
"name": "Low pulse speed"
},
"manual_speed": {
"name": "Manual speed"
},
"night_speed": {
"name": "Night speed"
}
},
"sensor": {
@@ -124,17 +99,8 @@
"rotor_stuck": "Rotor stuck"
}
},
"operating_time": {
"name": "Operating time"
},
"service_hours": {
"name": "Remaining hours until service"
},
"turn_feeding_time": {
"name": "Remaining off time after feeding"
},
"turn_off_time": {
"name": "Remaining off time"
}
},
"time": {

View File

@@ -4,7 +4,6 @@ from typing import Any, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
@@ -31,8 +30,8 @@ async def async_setup_entry(
"""Set up the switch entities for one or multiple devices."""
entities: list[SwitchEntity] = []
for device in device_address.values():
if isinstance(device, (EheimDigitalClassicVario, EheimDigitalFilter)):
entities.append(EheimDigitalFilterSwitch(coordinator, device)) # noqa: PERF401
if isinstance(device, EheimDigitalClassicVario):
entities.append(EheimDigitalClassicVarioSwitch(coordinator, device)) # noqa: PERF401
async_add_entities(entities)
@@ -40,10 +39,10 @@ async def async_setup_entry(
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalFilterSwitch(
EheimDigitalEntity[EheimDigitalClassicVario | EheimDigitalFilter], SwitchEntity
class EheimDigitalClassicVarioSwitch(
EheimDigitalEntity[EheimDigitalClassicVario], SwitchEntity
):
"""Represent an EHEIM Digital classicVARIO or filter switch entity."""
"""Represent an EHEIM Digital classicVARIO switch entity."""
_attr_translation_key = "filter_active"
_attr_name = None
@@ -51,9 +50,9 @@ class EheimDigitalFilterSwitch(
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: EheimDigitalClassicVario | EheimDigitalFilter,
device: EheimDigitalClassicVario,
) -> None:
"""Initialize an EHEIM Digital classicVARIO or filter switch entity."""
"""Initialize an EHEIM Digital classicVARIO switch entity."""
super().__init__(coordinator, device)
self._attr_unique_id = device.mac_address
self._async_update_attrs()

View File

@@ -7,7 +7,6 @@ from typing import Any, final, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.filter import EheimDigitalFilter
from eheimdigital.heater import EheimDigitalHeater
from homeassistant.components.time import TimeEntity, TimeEntityDescription
@@ -29,23 +28,6 @@ class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescri
set_value_fn: Callable[[_DeviceT, time], Awaitable[None]]
FILTER_DESCRIPTIONS: tuple[EheimDigitalTimeDescription[EheimDigitalFilter], ...] = (
EheimDigitalTimeDescription[EheimDigitalFilter](
key="day_start_time",
translation_key="day_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.day_start_time,
set_value_fn=lambda device, value: device.set_day_start_time(value),
),
EheimDigitalTimeDescription[EheimDigitalFilter](
key="night_start_time",
translation_key="night_start_time",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.night_start_time,
set_value_fn=lambda device, value: device.set_night_start_time(value),
),
)
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalTimeDescription[EheimDigitalClassicVario], ...
] = (
@@ -97,13 +79,6 @@ async def async_setup_entry(
"""Set up the time entities for one or multiple devices."""
entities: list[EheimDigitalTime[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalFilter):
entities.extend(
EheimDigitalTime[EheimDigitalFilter](
coordinator, device, description
)
for description in FILTER_DESCRIPTIONS
)
if isinstance(device, EheimDigitalClassicVario):
entities.extend(
EheimDigitalTime[EheimDigitalClassicVario](

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.3"],
"requirements": ["pyenphase==2.4.2"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

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