mirror of
https://github.com/home-assistant/core.git
synced 2026-03-16 16:02:06 +01:00
Compare commits
2 Commits
dev
...
state_attr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9537f7357 | ||
|
|
57a33dd34d |
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@@ -18,11 +18,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build variables
|
||||
id: vars
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
|
||||
- name: Build base image
|
||||
id: build
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -442,7 +442,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -456,7 +456,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -592,7 +592,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
@@ -605,7 +605,7 @@ jobs:
|
||||
- name: Push Docker image
|
||||
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
|
||||
id: push
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: . # So action will not pull the repository again
|
||||
file: ./script/hassfest/docker/Dockerfile
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -1400,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1570,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.23.1
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -570,7 +570,6 @@ homeassistant.components.trafikverket_train.*
|
||||
homeassistant.components.trafikverket_weatherstation.*
|
||||
homeassistant.components.transmission.*
|
||||
homeassistant.components.trend.*
|
||||
homeassistant.components.trmnl.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifi.*
|
||||
|
||||
@@ -15,11 +15,6 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
|
||||
|
||||
## Testing
|
||||
|
||||
When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
|
||||
## Good practices
|
||||
|
||||
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.
|
||||
|
||||
12
CODEOWNERS
generated
12
CODEOWNERS
generated
@@ -186,8 +186,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/auth/ @home-assistant/core
|
||||
/homeassistant/components/automation/ @home-assistant/core
|
||||
/tests/components/automation/ @home-assistant/core
|
||||
/homeassistant/components/autoskope/ @mcisk
|
||||
/tests/components/autoskope/ @mcisk
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @ricohageman
|
||||
/tests/components/awair/ @ahayworth @ricohageman
|
||||
@@ -1073,8 +1071,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/moon/ @fabaff @frenck
|
||||
/homeassistant/components/mopeka/ @bdraco
|
||||
/tests/components/mopeka/ @bdraco
|
||||
/homeassistant/components/motion/ @home-assistant/core
|
||||
/tests/components/motion/ @home-assistant/core
|
||||
/homeassistant/components/motion_blinds/ @starkillerOG
|
||||
/tests/components/motion_blinds/ @starkillerOG
|
||||
/homeassistant/components/motionblinds_ble/ @LennP @jerrybboy
|
||||
@@ -1188,8 +1184,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nzbget/ @chriscla
|
||||
/homeassistant/components/obihai/ @dshokouhi @ejpenney
|
||||
/tests/components/obihai/ @dshokouhi @ejpenney
|
||||
/homeassistant/components/occupancy/ @home-assistant/core
|
||||
/tests/components/occupancy/ @home-assistant/core
|
||||
/homeassistant/components/octoprint/ @rfleming71
|
||||
/tests/components/octoprint/ @rfleming71
|
||||
/homeassistant/components/ohmconnect/ @robbiet480
|
||||
@@ -1770,8 +1764,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/trend/ @jpbede
|
||||
/homeassistant/components/triggercmd/ @rvmey
|
||||
/tests/components/triggercmd/ @rvmey
|
||||
/homeassistant/components/trmnl/ @joostlek
|
||||
/tests/components/trmnl/ @joostlek
|
||||
/homeassistant/components/tts/ @home-assistant/core
|
||||
/tests/components/tts/ @home-assistant/core
|
||||
/homeassistant/components/tuya/ @Tuya @zlinoliver
|
||||
@@ -1788,8 +1780,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ukraine_alarm/ @PaulAnnekov
|
||||
/homeassistant/components/unifi/ @Kane610
|
||||
/tests/components/unifi/ @Kane610
|
||||
/homeassistant/components/unifi_access/ @imhotep @RaHehl
|
||||
/tests/components/unifi_access/ @imhotep @RaHehl
|
||||
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
|
||||
/homeassistant/components/unifiled/ @florisvdk
|
||||
/homeassistant/components/unifiprotect/ @RaHehl
|
||||
@@ -1915,8 +1905,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/wiffi/ @mampfes
|
||||
/homeassistant/components/wilight/ @leofig-rj
|
||||
/tests/components/wilight/ @leofig-rj
|
||||
/homeassistant/components/window/ @home-assistant/core
|
||||
/tests/components/window/ @home-assistant/core
|
||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||
/homeassistant/components/withings/ @joostlek
|
||||
/tests/components/withings/ @joostlek
|
||||
|
||||
@@ -245,9 +245,6 @@ DEFAULT_INTEGRATIONS = {
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"window",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"domain": "ubiquiti",
|
||||
"name": "Ubiquiti",
|
||||
"integrations": [
|
||||
"airos",
|
||||
"unifi",
|
||||
"unifi_access",
|
||||
"unifi_direct",
|
||||
"unifiled",
|
||||
"unifiprotect"
|
||||
]
|
||||
"integrations": ["airos", "unifi", "unifi_direct", "unifiled", "unifiprotect"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityStateConditionBase,
|
||||
@@ -44,7 +43,7 @@ def make_entity_state_required_features_condition(
|
||||
class CustomCondition(EntityStateRequiredFeaturesCondition):
|
||||
"""Condition for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domain = domain
|
||||
_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTargetStateTriggerBase,
|
||||
@@ -45,7 +44,7 @@ def make_entity_state_trigger_required_features(
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {domain: DomainSpec()}
|
||||
_domains = {domain}
|
||||
_to_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_DEVICE_TYPE
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -24,15 +25,20 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]):
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._serial_num = serial_num
|
||||
model = self.device.model
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial_num)},
|
||||
name=self.device.account_name,
|
||||
model=self.device.model,
|
||||
model=model,
|
||||
model_id=self.device.device_type,
|
||||
manufacturer=self.device.manufacturer or "Amazon",
|
||||
hw_version=self.device.hardware_version,
|
||||
sw_version=self.device.software_version,
|
||||
serial_number=serial_num,
|
||||
sw_version=(
|
||||
self.device.software_version
|
||||
if model != SPEAKER_GROUP_DEVICE_TYPE
|
||||
else None
|
||||
),
|
||||
serial_number=serial_num if model != SPEAKER_GROUP_DEVICE_TYPE else None,
|
||||
)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{serial_num}-{description.key}"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.0.1"]
|
||||
"requirements": ["aioamazondevices==13.0.0"]
|
||||
}
|
||||
|
||||
@@ -101,10 +101,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
assert method is not None
|
||||
|
||||
await method(self.device, state)
|
||||
self.coordinator.data[self.device.serial_number].sensors[
|
||||
self.entity_description.key
|
||||
].value = state
|
||||
self.async_write_ha_state()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
|
||||
@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -66,7 +66,6 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
|
||||
model="Arcam FMJ AVR",
|
||||
name=name,
|
||||
)
|
||||
self.zone_unique_id = f"{unique_id}-{zone}"
|
||||
|
||||
if zone != 1:
|
||||
self.device_info["via_device"] = (DOMAIN, unique_id)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Base entity for Arcam FMJ integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import ArcamFmjCoordinator
|
||||
|
||||
|
||||
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
|
||||
"""Base entity for Arcam FMJ."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_device_info = coordinator.device_info
|
||||
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
|
||||
self._attr_unique_id = coordinator.zone_unique_id
|
||||
if description is not None:
|
||||
self._attr_unique_id = f"{self._attr_unique_id}-{description.key}"
|
||||
self.entity_description = description
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"default": "mdi:reorder-horizontal"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"default": "mdi:surround-sound"
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"default": "mdi:dolby"
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"default": "mdi:aspect-ratio"
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"default": "mdi:palette"
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"default": "mdi:arrow-expand-horizontal"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"default": "mdi:animation"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"default": "mdi:arrow-expand-vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,10 @@ from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import EVENT_TURN_ON
|
||||
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +39,14 @@ async def async_setup_entry(
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
async_add_entities(
|
||||
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
|
||||
[
|
||||
ArcamFmj(
|
||||
config_entry.title,
|
||||
coordinators[zone],
|
||||
config_entry.unique_id or config_entry.entry_id,
|
||||
)
|
||||
for zone in (1, 2)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -60,13 +67,21 @@ def convert_exception[**_P, _R](
|
||||
return _convert_exception
|
||||
|
||||
|
||||
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
|
||||
"""Representation of a media device."""
|
||||
|
||||
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device_name: str,
|
||||
coordinator: ArcamFmjCoordinator,
|
||||
uuid: str,
|
||||
) -> None:
|
||||
"""Initialize device."""
|
||||
super().__init__(coordinator)
|
||||
self._state = coordinator.state
|
||||
self._attr_name = f"Zone {self._state.zn}"
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -79,6 +94,9 @@ class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
|
||||
)
|
||||
if self._state.zn == 1:
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_unique_id = f"{uuid}-{self._state.zn}"
|
||||
self._attr_entity_registry_enabled_default = self._state.zn == 1
|
||||
self._attr_device_info = coordinator.device_info
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"""Arcam sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace
|
||||
from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfFrequency
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes an Arcam FMJ sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], int | float | str | None]
|
||||
|
||||
|
||||
SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = (
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_horizontal_resolution",
|
||||
translation_key="incoming_video_horizontal_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.horizontal_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_vertical_resolution",
|
||||
translation_key="incoming_video_vertical_resolution",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement="px",
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.vertical_resolution
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_refresh_rate",
|
||||
translation_key="incoming_video_refresh_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
vp.refresh_rate
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_aspect_ratio",
|
||||
translation_key="incoming_video_aspect_ratio",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoAspectRatio],
|
||||
value_fn=lambda state: (
|
||||
vp.aspect_ratio.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_video_colorspace",
|
||||
translation_key="incoming_video_colorspace",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingVideoColorspace],
|
||||
value_fn=lambda state: (
|
||||
vp.colorspace.name.lower()
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_format",
|
||||
translation_key="incoming_audio_format",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioFormat],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[0]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_config",
|
||||
translation_key="incoming_audio_config",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[member.name.lower() for member in IncomingAudioConfig],
|
||||
value_fn=lambda state: (
|
||||
result.name.lower()
|
||||
if (result := state.get_incoming_audio_format()[1]) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
ArcamFmjSensorEntityDescription(
|
||||
key="incoming_audio_sample_rate",
|
||||
translation_key="incoming_audio_sample_rate",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda state: (
|
||||
None
|
||||
if (sample_rate := state.get_incoming_audio_sample_rate()) == 0
|
||||
else sample_rate
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjSensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjSensorEntity(coordinator, description) for description in SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjSensorEntity(ArcamFmjEntity, SensorEntity):
|
||||
"""Representation of an Arcam FMJ sensor."""
|
||||
|
||||
entity_description: ArcamFmjSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Return the sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -23,121 +23,5 @@
|
||||
"trigger_type": {
|
||||
"turn_on": "{entity_name} was requested to turn on"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"name": "Incoming video interlaced"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"name": "Incoming audio configuration",
|
||||
"state": {
|
||||
"auro_10_1": "Auro 10.1",
|
||||
"auro_11_1": "Auro 11.1",
|
||||
"auro_13_1": "Auro 13.1",
|
||||
"auro_2_2_2": "Auro 2.2.2",
|
||||
"auro_5_0": "Auro 5.0",
|
||||
"auro_5_1": "Auro 5.1",
|
||||
"auro_8_0": "Auro 8.0",
|
||||
"auro_9_1": "Auro 9.1",
|
||||
"auro_quad": "Auro quad",
|
||||
"dual_mono": "Dual mono",
|
||||
"dual_mono_lfe": "Dual mono + LFE",
|
||||
"mono": "Mono",
|
||||
"mono_lfe": "Mono + LFE",
|
||||
"stereo_center": "Stereo center",
|
||||
"stereo_center_lfe": "Stereo center + LFE",
|
||||
"stereo_center_surr_lr": "Stereo center surround L/R",
|
||||
"stereo_center_surr_lr_back_lr": "Stereo center surround L/R back L/R",
|
||||
"stereo_center_surr_lr_back_lr_lfe": "Stereo center surround L/R back L/R + LFE",
|
||||
"stereo_center_surr_lr_back_matrix": "Stereo center surround L/R back matrix",
|
||||
"stereo_center_surr_lr_back_matrix_lfe": "Stereo center surround L/R back matrix + LFE",
|
||||
"stereo_center_surr_lr_back_mono": "Stereo center surround L/R back mono",
|
||||
"stereo_center_surr_lr_back_mono_lfe": "Stereo center surround L/R back mono + LFE",
|
||||
"stereo_center_surr_lr_lfe": "Stereo center surround L/R + LFE",
|
||||
"stereo_center_surr_mono": "Stereo center surround mono",
|
||||
"stereo_center_surr_mono_lfe": "Stereo center surround mono + LFE",
|
||||
"stereo_downmix": "Stereo downmix",
|
||||
"stereo_downmix_lfe": "Stereo downmix + LFE",
|
||||
"stereo_lfe": "Stereo + LFE",
|
||||
"stereo_only": "Stereo only",
|
||||
"stereo_only_lo_ro": "Stereo only Lo/Ro",
|
||||
"stereo_only_lo_ro_lfe": "Stereo only Lo/Ro + LFE",
|
||||
"stereo_surr_lr": "Stereo surround L/R",
|
||||
"stereo_surr_lr_back_lr": "Stereo surround L/R back L/R",
|
||||
"stereo_surr_lr_back_lr_lfe": "Stereo surround L/R back L/R + LFE",
|
||||
"stereo_surr_lr_back_matrix": "Stereo surround L/R back matrix",
|
||||
"stereo_surr_lr_back_matrix_lfe": "Stereo surround L/R back matrix + LFE",
|
||||
"stereo_surr_lr_back_mono": "Stereo surround L/R back mono",
|
||||
"stereo_surr_lr_back_mono_lfe": "Stereo surround L/R back mono + LFE",
|
||||
"stereo_surr_lr_lfe": "Stereo surround L/R + LFE",
|
||||
"stereo_surr_mono": "Stereo surround mono",
|
||||
"stereo_surr_mono_lfe": "Stereo surround mono + LFE",
|
||||
"undetected": "Undetected",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"name": "Incoming audio format",
|
||||
"state": {
|
||||
"analogue_direct": "Analogue direct",
|
||||
"auro_3d": "Auro-3D",
|
||||
"dolby_atmos": "Dolby Atmos",
|
||||
"dolby_digital": "Dolby Digital",
|
||||
"dolby_digital_ex": "Dolby Digital EX",
|
||||
"dolby_digital_plus": "Dolby Digital Plus",
|
||||
"dolby_digital_surround": "Dolby Digital Surround",
|
||||
"dolby_digital_true_hd": "Dolby TrueHD",
|
||||
"dts": "DTS",
|
||||
"dts_96_24": "DTS 96/24",
|
||||
"dts_core": "DTS Core",
|
||||
"dts_es_discrete": "DTS-ES Discrete",
|
||||
"dts_es_discrete_96_24": "DTS-ES Discrete 96/24",
|
||||
"dts_es_matrix": "DTS-ES Matrix",
|
||||
"dts_es_matrix_96_24": "DTS-ES Matrix 96/24",
|
||||
"dts_hd_high_res_audio": "DTS-HD High Resolution Audio",
|
||||
"dts_hd_master_audio": "DTS-HD Master Audio",
|
||||
"dts_low_bit_rate": "DTS Low Bit Rate",
|
||||
"dts_x": "DTS:X",
|
||||
"imax_enhanced": "IMAX Enhanced",
|
||||
"pcm": "PCM",
|
||||
"pcm_zero": "PCM zero",
|
||||
"undetected": "Undetected",
|
||||
"unsupported": "Unsupported"
|
||||
}
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"name": "Incoming audio sample rate"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"name": "Incoming video aspect ratio",
|
||||
"state": {
|
||||
"aspect_16_9": "16:9",
|
||||
"aspect_4_3": "4:3",
|
||||
"undefined": "Undefined"
|
||||
}
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"name": "Incoming video colorspace",
|
||||
"state": {
|
||||
"dolby_vision": "Dolby Vision",
|
||||
"hdr10": "HDR10",
|
||||
"hdr10_plus": "HDR10+",
|
||||
"hlg": "HLG",
|
||||
"normal": "Normal"
|
||||
}
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"name": "Incoming video horizontal resolution"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"name": "Incoming video refresh rate"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"name": "Incoming video vertical resolution"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +78,19 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant",
|
||||
"name": "Assistant{index}",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from aiohttp import ClientError
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.exceptions import AugustApiAIOHTTPError
|
||||
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
|
||||
from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
@@ -13,12 +13,7 @@ from yalexs.manager.gateway import Config as YaleXSConfig
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
@@ -50,18 +45,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
|
||||
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
|
||||
try:
|
||||
await async_setup_august(hass, entry, august_gateway)
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (RequireValidation, InvalidAuth) as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except TimeoutError as err:
|
||||
raise ConfigEntryNotReady("Timed out connecting to august api") from err
|
||||
except (
|
||||
AugustApiAIOHTTPError,
|
||||
OAuth2TokenRequestError,
|
||||
ClientError,
|
||||
CannotConnect,
|
||||
) as err:
|
||||
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
@@ -152,8 +152,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"motion",
|
||||
"occupancy",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
@@ -163,7 +161,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"text",
|
||||
"update",
|
||||
"vacuum",
|
||||
"window",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""The Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiohttp
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DEFAULT_HOST
|
||||
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
|
||||
"""Set up Autoskope from a config entry."""
|
||||
session = async_create_clientsession(hass, cookie_jar=aiohttp.CookieJar())
|
||||
|
||||
api = AutoskopeApi(
|
||||
host=entry.data.get(CONF_HOST, DEFAULT_HOST),
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
session=session,
|
||||
)
|
||||
|
||||
try:
|
||||
await api.connect()
|
||||
except InvalidAuth as err:
|
||||
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
|
||||
raise ConfigEntryError(
|
||||
"Authentication failed, please check credentials"
|
||||
) from err
|
||||
except CannotConnect as err:
|
||||
raise ConfigEntryNotReady("Could not connect to Autoskope API") from err
|
||||
|
||||
coordinator = AutoskopeDataUpdateCoordinator(hass, api, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Config flow for the Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.data_entry_flow import section
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
}
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Autoskope."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
username = user_input[CONF_USERNAME].lower()
|
||||
host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower()
|
||||
|
||||
try:
|
||||
cv.url(host)
|
||||
except vol.Invalid:
|
||||
errors["base"] = "invalid_url"
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(f"{username}@{host}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=f"Autoskope ({username})",
|
||||
data={
|
||||
CONF_USERNAME: username,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_HOST: host,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Constants for the Autoskope integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "autoskope"
|
||||
|
||||
DEFAULT_HOST = "https://portal.autoskope.de"
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
@@ -1,60 +0,0 @@
|
||||
"""Data update coordinator for the Autoskope integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
from autoskope_client.models import CannotConnect, InvalidAuth, Vehicle
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type AutoskopeConfigEntry = ConfigEntry[AutoskopeDataUpdateCoordinator]
|
||||
|
||||
|
||||
class AutoskopeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
|
||||
"""Class to manage fetching Autoskope data."""
|
||||
|
||||
config_entry: AutoskopeConfigEntry
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, api: AutoskopeApi, entry: AutoskopeConfigEntry
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
self.api = api
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Vehicle]:
|
||||
"""Fetch data from API endpoint."""
|
||||
try:
|
||||
vehicles = await self.api.get_vehicles()
|
||||
return {vehicle.id: vehicle for vehicle in vehicles}
|
||||
|
||||
except InvalidAuth:
|
||||
# Attempt to re-authenticate using stored credentials
|
||||
try:
|
||||
await self.api.authenticate()
|
||||
# Retry the request after successful re-authentication
|
||||
vehicles = await self.api.get_vehicles()
|
||||
return {vehicle.id: vehicle for vehicle in vehicles}
|
||||
except InvalidAuth as reauth_err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Authentication failed: {reauth_err}"
|
||||
) from reauth_err
|
||||
|
||||
except CannotConnect as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Support for Autoskope device tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from autoskope_client.constants import MANUFACTURER
|
||||
from autoskope_client.models import Vehicle
|
||||
|
||||
from homeassistant.components.device_tracker import SourceType, TrackerEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AutoskopeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Autoskope device tracker entities."""
|
||||
coordinator: AutoskopeDataUpdateCoordinator = entry.runtime_data
|
||||
tracked_vehicles: set[str] = set()
|
||||
|
||||
@callback
|
||||
def update_entities() -> None:
|
||||
"""Update entities based on coordinator data."""
|
||||
current_vehicles = set(coordinator.data.keys())
|
||||
vehicles_to_add = current_vehicles - tracked_vehicles
|
||||
|
||||
if vehicles_to_add:
|
||||
new_entities = [
|
||||
AutoskopeDeviceTracker(coordinator, vehicle_id)
|
||||
for vehicle_id in vehicles_to_add
|
||||
]
|
||||
tracked_vehicles.update(vehicles_to_add)
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(update_entities))
|
||||
update_entities()
|
||||
|
||||
|
||||
class AutoskopeDeviceTracker(
|
||||
CoordinatorEntity[AutoskopeDataUpdateCoordinator], TrackerEntity
|
||||
):
|
||||
"""Representation of an Autoskope tracked device."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name: str | None = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: AutoskopeDataUpdateCoordinator, vehicle_id: str
|
||||
) -> None:
|
||||
"""Initialize the TrackerEntity."""
|
||||
super().__init__(coordinator)
|
||||
self._vehicle_id = vehicle_id
|
||||
self._attr_unique_id = vehicle_id
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (
|
||||
self._vehicle_id in self.coordinator.data
|
||||
and (device_entry := self.device_entry) is not None
|
||||
and device_entry.name != self._vehicle_data.name
|
||||
):
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, name=self._vehicle_data.name
|
||||
)
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return device info for the vehicle."""
|
||||
vehicle = self.coordinator.data[self._vehicle_id]
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, str(vehicle.id))},
|
||||
name=vehicle.name,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=vehicle.model,
|
||||
serial_number=vehicle.imei,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.coordinator.data is not None
|
||||
and self._vehicle_id in self.coordinator.data
|
||||
)
|
||||
|
||||
@property
|
||||
def _vehicle_data(self) -> Vehicle:
|
||||
"""Return the vehicle data for the current entity."""
|
||||
return self.coordinator.data[self._vehicle_id]
|
||||
|
||||
@property
|
||||
def latitude(self) -> float | None:
|
||||
"""Return latitude value of the device."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.position:
|
||||
return float(vehicle.position.latitude)
|
||||
return None
|
||||
|
||||
@property
|
||||
def longitude(self) -> float | None:
|
||||
"""Return longitude value of the device."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.position:
|
||||
return float(vehicle.position.longitude)
|
||||
return None
|
||||
|
||||
@property
|
||||
def source_type(self) -> SourceType:
|
||||
"""Return the source type of the device."""
|
||||
return SourceType.GPS
|
||||
|
||||
@property
|
||||
def location_accuracy(self) -> float:
|
||||
"""Return the location accuracy of the device in meters."""
|
||||
if (vehicle := self._vehicle_data) and vehicle.gps_quality:
|
||||
if vehicle.gps_quality > 0:
|
||||
# HDOP to estimated accuracy in meters
|
||||
# HDOP of 1-2 = good (5-10m), 2-5 = moderate (10-25m), >5 = poor (>25m)
|
||||
return float(max(5, int(vehicle.gps_quality * 5.0)))
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def icon(self) -> str:
|
||||
"""Return the icon based on the vehicle's activity."""
|
||||
if self._vehicle_id not in self.coordinator.data:
|
||||
return "mdi:car-clock"
|
||||
vehicle = self._vehicle_data
|
||||
if vehicle.position:
|
||||
if vehicle.position.park_mode:
|
||||
return "mdi:car-brake-parking"
|
||||
if vehicle.position.speed > 5: # Moving threshold: 5 km/h
|
||||
return "mdi:car-arrow-right"
|
||||
return "mdi:car"
|
||||
return "mdi:car-clock"
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "autoskope",
|
||||
"name": "Autoskope",
|
||||
"codeowners": ["@mcisk"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/autoskope",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["autoskope_client==1.4.1"]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
# + in comment indicates requirement for quality scale
|
||||
# - in comment indicates issue to be fixed, not impacting quality scale
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom services.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom services.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not provide custom services.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Reauthentication flow removed for initial PR, will be added in follow-up.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
Only one entity type (device_tracker) is created, making this not applicable.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Reconfiguration flow removed for initial PR, will be added in follow-up.
|
||||
repair-issues: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: |
|
||||
Integration needs to be added to .strict-typing file for full compliance.
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_url": "Invalid URL",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password for your Autoskope account.",
|
||||
"username": "The username for your Autoskope account."
|
||||
},
|
||||
"description": "Enter your Autoskope credentials.",
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"data": {
|
||||
"host": "API endpoint"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal."
|
||||
},
|
||||
"name": "Advanced settings"
|
||||
}
|
||||
},
|
||||
"title": "Connect to Autoskope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"cannot_connect": {
|
||||
"description": "Home Assistant could not connect to the Autoskope API at {host}. Please check the connection details and ensure the API endpoint is reachable.\n\nError: {error}",
|
||||
"title": "Failed to connect to Autoskope"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"description": "Authentication with Autoskope failed for user {username}. Please re-authenticate the integration with the correct password.",
|
||||
"title": "Invalid Autoskope authentication"
|
||||
},
|
||||
"low_battery": {
|
||||
"description": "The battery voltage for vehicle {vehicle_name} ({vehicle_id}) is low ({value}V). Consider checking or replacing the battery.",
|
||||
"title": "Low vehicle battery ({vehicle_name})"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,6 @@ class CreateBackupStage(StrEnum):
|
||||
ADDONS = "addons"
|
||||
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
|
||||
DOCKER_CONFIG = "docker_config"
|
||||
CLEANING_UP = "cleaning_up"
|
||||
FINISHING_FILE = "finishing_file"
|
||||
FOLDERS = "folders"
|
||||
HOME_ASSISTANT = "home_assistant"
|
||||
@@ -1291,13 +1290,6 @@ class BackupManager:
|
||||
)
|
||||
# delete old backups more numerous than copies
|
||||
# try this regardless of agent errors above
|
||||
self.async_on_backup_event(
|
||||
CreateBackupEvent(
|
||||
reason=None,
|
||||
stage=CreateBackupStage.CLEANING_UP,
|
||||
state=CreateBackupState.IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
await delete_backups_exceeding_configured_count(self)
|
||||
|
||||
finally:
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.10.2"
|
||||
"habluetooth==5.9.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA,
|
||||
EntityTriggerBase,
|
||||
@@ -15,7 +14,7 @@ from . import DOMAIN
|
||||
class ButtonPressedTrigger(EntityTriggerBase):
|
||||
"""Trigger for button entity presses."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_domains = {DOMAIN}
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Diagnostics support for Chess.com."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ChessConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: ChessConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"player": asdict(coordinator.data.player),
|
||||
"stats": asdict(coordinator.data.stats),
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Can't detect a game
|
||||
|
||||
@@ -5,14 +5,14 @@ import voluptuous as vol
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
TriggerConfig,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
@@ -35,7 +35,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
|
||||
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
_domain_specs = {DOMAIN: DomainSpec()}
|
||||
_domains = {DOMAIN}
|
||||
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
@@ -46,23 +46,23 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_entity_transition_trigger(
|
||||
@@ -79,8 +79,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -516,8 +516,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
hass_info: dict[str, Any],
|
||||
domains_info: dict[str, dict[str, str]],
|
||||
) -> str:
|
||||
cloud = hass.data[DATA_CLOUD]
|
||||
|
||||
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
|
||||
if len(domain_info) == 0:
|
||||
return "No information available\n"
|
||||
@@ -574,15 +572,6 @@ class DownloadSupportPackageView(HomeAssistantView):
|
||||
"</details>\n\n"
|
||||
)
|
||||
|
||||
# Add stored latency response if available
|
||||
if locations := cloud.remote.latency_by_location:
|
||||
markdown += "## Latency by location\n\n"
|
||||
markdown += "Location | Latency (ms)\n"
|
||||
markdown += "--- | ---\n"
|
||||
for location in sorted(locations):
|
||||
markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n"
|
||||
markdown += "\n"
|
||||
|
||||
# Add installed packages section
|
||||
try:
|
||||
installed_packages = await async_get_installed_packages()
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
|
||||
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
INTENT_OPEN_COVER,
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_COVER,
|
||||
"Opening {}",
|
||||
description="Opens a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
@@ -26,6 +27,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
INTENT_CLOSE_COVER,
|
||||
DOMAIN,
|
||||
SERVICE_CLOSE_COVER,
|
||||
"Closing {}",
|
||||
description="Closes a cover",
|
||||
platforms={DOMAIN},
|
||||
device_classes={CoverDeviceClass},
|
||||
|
||||
@@ -1,82 +1,81 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CoverDomainSpec(DomainSpec):
|
||||
"""DomainSpec with a target value for comparison."""
|
||||
|
||||
target_value: str | bool | None = None
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
|
||||
class CoverTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for cover state changes."""
|
||||
|
||||
def _get_value(self, state: State) -> str | bool | None:
|
||||
"""Extract the relevant value from state based on domain spec."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
if domain_spec.value_source is not None:
|
||||
return state.attributes.get(domain_spec.value_source)
|
||||
return state.state
|
||||
_binary_sensor_target_state: str
|
||||
_cover_is_closed_target_value: bool
|
||||
_device_classes: dict[str, str]
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities by cover device class."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_classes[split_entity_id(entity_id)[0]]
|
||||
}
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
|
||||
return self._get_value(state) == domain_spec.target_value
|
||||
if split_entity_id(state.entity_id)[0] == DOMAIN:
|
||||
return (
|
||||
state.attributes.get(ATTR_IS_CLOSED)
|
||||
== self._cover_is_closed_target_value
|
||||
)
|
||||
return state.state == self._binary_sensor_target_state
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the transition is valid for a cover state change."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
if (from_value := self._get_value(from_state)) is None:
|
||||
return False
|
||||
return from_value != self._get_value(to_state)
|
||||
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
|
||||
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
|
||||
return False
|
||||
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
*, device_classes: dict[str, str]
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_opened."""
|
||||
|
||||
class CoverOpenedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover opened state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=False if domain == DOMAIN else STATE_ON,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverOpenedTrigger
|
||||
|
||||
|
||||
def make_cover_closed_trigger(
|
||||
*, device_classes: dict[str, str]
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_closed."""
|
||||
|
||||
class CoverClosedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover closed state changes."""
|
||||
|
||||
_domain_specs = {
|
||||
domain: CoverDomainSpec(
|
||||
device_class=dc,
|
||||
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
|
||||
target_value=True if domain == DOMAIN else STATE_OFF,
|
||||
)
|
||||
for domain, dc in device_classes.items()
|
||||
}
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverClosedTrigger
|
||||
|
||||
|
||||
@@ -20,8 +20,14 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR),
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.4.6"],
|
||||
"requirements": ["pyenphase==2.4.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.5.2",
|
||||
"aioesphomeapi==44.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,13 +123,19 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,12 +107,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -122,18 +116,11 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word",
|
||||
"name": "Wake word{index}",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,13 +5,7 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from aioesphomeapi import (
|
||||
EntityInfo,
|
||||
WaterHeaterFeature,
|
||||
WaterHeaterInfo,
|
||||
WaterHeaterMode,
|
||||
WaterHeaterState,
|
||||
)
|
||||
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
WaterHeaterEntity,
|
||||
@@ -60,7 +54,6 @@ class EsphomeWaterHeater(
|
||||
static_info = self._static_info
|
||||
self._attr_min_temp = static_info.min_temperature
|
||||
self._attr_max_temp = static_info.max_temperature
|
||||
self._attr_target_temperature_step = static_info.target_temperature_step
|
||||
features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
if static_info.supported_modes:
|
||||
features |= WaterHeaterEntityFeature.OPERATION_MODE
|
||||
@@ -70,8 +63,6 @@ class EsphomeWaterHeater(
|
||||
]
|
||||
else:
|
||||
self._attr_operation_list = None
|
||||
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
|
||||
features |= WaterHeaterEntityFeature.ON_OFF
|
||||
self._attr_supported_features = features
|
||||
|
||||
@property
|
||||
@@ -110,24 +101,6 @@ class EsphomeWaterHeater(
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater on."""
|
||||
self._client.water_heater_command(
|
||||
key=self._key,
|
||||
on=True,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the water heater off."""
|
||||
self._client.water_heater_command(
|
||||
key=self._key,
|
||||
on=False,
|
||||
device_id=self._static_info.device_id,
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
platform_async_setup_entry,
|
||||
|
||||
@@ -103,8 +103,6 @@ class FreeboxFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Initialize flow from zeroconf."""
|
||||
zeroconf_properties = discovery_info.properties
|
||||
host = zeroconf_properties.get("api_domain")
|
||||
if not host:
|
||||
return self.async_abort(reason="missing_api_domain")
|
||||
port = zeroconf_properties.get("https_port") or discovery_info.port
|
||||
host = zeroconf_properties["api_domain"]
|
||||
port = zeroconf_properties["https_port"]
|
||||
return await self.async_step_user({CONF_HOST: host, CONF_PORT: port})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"missing_api_domain": "The discovered Freebox service did not provide the required API domain. Try again later or configure the Freebox manually."
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,9 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -133,20 +133,26 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
networks: dict = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = network_info
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -428,11 +434,13 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -517,11 +525,12 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -532,11 +541,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict[str, Any],
|
||||
network_data: dict,
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -552,7 +560,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["NewEnable"],
|
||||
init_state=network_data["enabled"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -579,9 +587,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -179,9 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return PRESET_HOLIDAY
|
||||
if self.data.summer_active:
|
||||
return PRESET_SUMMER
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
|
||||
self.data, "boost_active", False
|
||||
):
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE:
|
||||
return PRESET_BOOST
|
||||
if self.data.target_temperature == self.data.comfort_temperature:
|
||||
return PRESET_COMFORT
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260312.0"]
|
||||
"requirements": ["home-assistant-frontend==20260304.0"]
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
)
|
||||
|
||||
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
|
||||
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,13 +91,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
helper_config_entry_id=config_entry.entry_id,
|
||||
source_device_id=source_device_id,
|
||||
)
|
||||
if config_entry.minor_version < 3:
|
||||
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
|
||||
if CONF_MIN_DUR in options:
|
||||
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, options=options, minor_version=3
|
||||
config_entry, options=options, minor_version=2
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
@@ -39,9 +38,7 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
DOMAIN as HOMEASSISTANT_DOMAIN,
|
||||
Context,
|
||||
CoreState,
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
@@ -49,30 +46,27 @@ from homeassistant.core import (
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.exceptions import ConditionError
|
||||
from homeassistant.helpers import condition, config_validation as cv
|
||||
from homeassistant.helpers.device import async_entity_id_to_device
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.event import (
|
||||
async_call_later,
|
||||
async_track_state_change_event,
|
||||
async_track_time_interval,
|
||||
)
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_DUR_COOLDOWN,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_DUR,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -104,8 +98,6 @@ PLATFORM_SCHEMA_COMMON = vol.Schema(
|
||||
vol.Optional(CONF_AC_MODE): cv.boolean,
|
||||
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
|
||||
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
|
||||
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
|
||||
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
|
||||
@@ -175,8 +167,6 @@ async def _async_setup_config(
|
||||
target_temp: float | None = config.get(CONF_TARGET_TEMP)
|
||||
ac_mode: bool | None = config.get(CONF_AC_MODE)
|
||||
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
|
||||
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
|
||||
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
|
||||
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
|
||||
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
|
||||
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
|
||||
@@ -200,8 +190,6 @@ async def _async_setup_config(
|
||||
target_temp=target_temp,
|
||||
ac_mode=ac_mode,
|
||||
min_cycle_duration=min_cycle_duration,
|
||||
max_cycle_duration=max_cycle_duration,
|
||||
cycle_cooldown=cycle_cooldown,
|
||||
cold_tolerance=cold_tolerance,
|
||||
hot_tolerance=hot_tolerance,
|
||||
keep_alive=keep_alive,
|
||||
@@ -233,8 +221,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
target_temp: float | None,
|
||||
ac_mode: bool | None,
|
||||
min_cycle_duration: timedelta | None,
|
||||
max_cycle_duration: timedelta | None,
|
||||
cycle_cooldown: timedelta | None,
|
||||
cold_tolerance: float,
|
||||
hot_tolerance: float,
|
||||
keep_alive: timedelta | None,
|
||||
@@ -254,16 +240,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
heater_entity_id,
|
||||
)
|
||||
self.ac_mode = ac_mode
|
||||
self.min_cycle_duration = min_cycle_duration or timedelta()
|
||||
self.max_cycle_duration = max_cycle_duration
|
||||
self.cycle_cooldown = cycle_cooldown or timedelta()
|
||||
self.min_cycle_duration = min_cycle_duration
|
||||
self._cold_tolerance = cold_tolerance
|
||||
# Subtract the cooldown so it doesn't impact startup
|
||||
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
|
||||
self._cycle_callback: CALLBACK_TYPE | None = None
|
||||
self._check_callback: CALLBACK_TYPE | None = None
|
||||
# Context ID used to detect our own toggles
|
||||
self._last_context_id: str | None = None
|
||||
self._hot_tolerance = hot_tolerance
|
||||
self._keep_alive = keep_alive
|
||||
self._hvac_mode = initial_hvac_mode
|
||||
@@ -311,7 +289,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self.hass, [self.heater_entity_id], self._async_switch_changed
|
||||
)
|
||||
)
|
||||
self.async_on_remove(self._cancel_timers)
|
||||
|
||||
if self._keep_alive:
|
||||
self.async_on_remove(
|
||||
@@ -505,18 +482,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
self.hass.async_create_task(
|
||||
self._check_switch_initial_state(), eager_start=True
|
||||
)
|
||||
|
||||
# Update timestamp on toggle
|
||||
self._last_toggled_time = new_state.last_changed
|
||||
|
||||
# If the user toggles the switch, assume they want control and clear the timers.
|
||||
# Note: If a manual interaction occurs within the 2s context window of a switch
|
||||
# toggle initiated by us, we may not detect manual control. Users are advised to
|
||||
# use the climate entity for reliable control, not the switch entity.
|
||||
if new_state.context.id != self._last_context_id:
|
||||
_LOGGER.debug("External switch change detected, clearing timers")
|
||||
self._last_context_id = None
|
||||
self._cancel_timers()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
@@ -552,69 +517,57 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
if not self._active or self._hvac_mode == HVACMode.OFF:
|
||||
return
|
||||
|
||||
if force and time is not None and self.max_cycle_duration:
|
||||
# We were invoked due to `max_cycle_duration`, so turn off
|
||||
_LOGGER.debug(
|
||||
"Turning off heater %s due to max cycle time of %s",
|
||||
self.heater_entity_id,
|
||||
self.max_cycle_duration,
|
||||
)
|
||||
self._cancel_cycle_timer()
|
||||
await self._async_heater_turn_off()
|
||||
return
|
||||
# If the `force` argument is True, we
|
||||
# ignore `min_cycle_duration`.
|
||||
# If the `time` argument is not none, we were invoked for
|
||||
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
|
||||
if not force and time is None and self.min_cycle_duration:
|
||||
if self._is_device_active:
|
||||
current_state = STATE_ON
|
||||
else:
|
||||
current_state = HVACMode.OFF
|
||||
try:
|
||||
long_enough = condition.state(
|
||||
self.hass,
|
||||
self.heater_entity_id,
|
||||
current_state,
|
||||
self.min_cycle_duration,
|
||||
)
|
||||
except ConditionError:
|
||||
long_enough = False
|
||||
|
||||
if not long_enough:
|
||||
return
|
||||
|
||||
assert self._cur_temp is not None and self._target_temp is not None
|
||||
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
|
||||
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
|
||||
now = dt_util.utcnow()
|
||||
|
||||
min_temp = self._target_temp - self._cold_tolerance
|
||||
max_temp = self._target_temp + self._hot_tolerance
|
||||
|
||||
if self._is_device_active:
|
||||
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
|
||||
# Make sure it's past the `min_cycle_duration` before turning off
|
||||
if (
|
||||
self._last_toggled_time + self.min_cycle_duration <= now
|
||||
or force
|
||||
):
|
||||
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_off()
|
||||
elif self._check_callback is None:
|
||||
_LOGGER.debug(
|
||||
"Minimum cycle time not reached, check again at %s",
|
||||
self._last_toggled_time + self.min_cycle_duration,
|
||||
)
|
||||
self._check_callback = async_call_later(
|
||||
self.hass,
|
||||
now - self._last_toggled_time + self.min_cycle_duration,
|
||||
self._async_timer_control_heating,
|
||||
)
|
||||
if (self.ac_mode and self._cur_temp <= min_temp) or (
|
||||
not self.ac_mode and self._cur_temp >= max_temp
|
||||
):
|
||||
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_off()
|
||||
elif time is not None:
|
||||
# This is a keep-alive call, so ensure it's on
|
||||
# The time argument is passed only in keep-alive case
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning on heater %s",
|
||||
"Keep-alive - Turning on heater heater %s",
|
||||
self.heater_entity_id,
|
||||
)
|
||||
await self._async_heater_turn_on(keepalive=True)
|
||||
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
|
||||
# Make sure it's past the `cycle_cooldown` before turning on
|
||||
if self._last_toggled_time + self.cycle_cooldown <= now or force:
|
||||
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_on()
|
||||
elif self._check_callback is None:
|
||||
_LOGGER.debug(
|
||||
"Cooldown time not reached, check again at %s",
|
||||
self._last_toggled_time + self.cycle_cooldown,
|
||||
)
|
||||
self._check_callback = async_call_later(
|
||||
self.hass,
|
||||
now - self._last_toggled_time + self.cycle_cooldown,
|
||||
self._async_timer_control_heating,
|
||||
)
|
||||
elif (self.ac_mode and self._cur_temp > max_temp) or (
|
||||
not self.ac_mode and self._cur_temp < min_temp
|
||||
):
|
||||
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
|
||||
await self._async_heater_turn_on()
|
||||
elif time is not None:
|
||||
# This is a keep-alive call, so ensure it's off
|
||||
# The time argument is passed only in keep-alive case
|
||||
_LOGGER.debug(
|
||||
"Keep-alive - Turning off heater %s", self.heater_entity_id
|
||||
)
|
||||
await self._async_heater_turn_off(keepalive=True)
|
||||
await self._async_heater_turn_off()
|
||||
|
||||
@property
|
||||
def _is_device_active(self) -> bool | None:
|
||||
@@ -624,48 +577,19 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
|
||||
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
|
||||
|
||||
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
|
||||
async def _async_heater_turn_on(self) -> None:
|
||||
"""Turn heater toggleable device on."""
|
||||
data = {ATTR_ENTITY_ID: self.heater_entity_id}
|
||||
# Create a new context for this service call so we can identify
|
||||
# the resulting state change event as originating from us
|
||||
new_context = Context(parent_id=self._context.id if self._context else None)
|
||||
self.async_set_context(new_context)
|
||||
self._last_context_id = new_context.id
|
||||
await self.hass.services.async_call(
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
|
||||
)
|
||||
if not keepalive:
|
||||
# Update timestamp on turn on
|
||||
self._last_toggled_time = dt_util.utcnow()
|
||||
self._cancel_check_timer()
|
||||
if self.max_cycle_duration:
|
||||
_LOGGER.debug(
|
||||
"Scheduling maximum run-time shut-off for %s",
|
||||
self._last_toggled_time + self.max_cycle_duration,
|
||||
)
|
||||
self._cancel_cycle_timer()
|
||||
self._cycle_callback = async_call_later(
|
||||
self.hass,
|
||||
self.max_cycle_duration,
|
||||
partial(self._async_control_heating, force=True),
|
||||
)
|
||||
|
||||
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
|
||||
async def _async_heater_turn_off(self) -> None:
|
||||
"""Turn heater toggleable device off."""
|
||||
data = {ATTR_ENTITY_ID: self.heater_entity_id}
|
||||
# Create a new context for this service call so we can identify
|
||||
# the resulting state change event as originating from us
|
||||
new_context = Context(parent_id=self._context.id if self._context else None)
|
||||
self.async_set_context(new_context)
|
||||
self._last_context_id = new_context.id
|
||||
await self.hass.services.async_call(
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
|
||||
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
|
||||
)
|
||||
if not keepalive:
|
||||
# Update timestamp on turn off
|
||||
self._last_toggled_time = dt_util.utcnow()
|
||||
self._cancel_timers()
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
@@ -689,30 +613,3 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
await self._async_control_heating(force=True)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
|
||||
"""Reset check timer and control heating."""
|
||||
self._check_callback = None
|
||||
await self._async_control_heating()
|
||||
|
||||
@callback
|
||||
def _cancel_check_timer(self) -> None:
|
||||
"""Reset check timer."""
|
||||
if self._check_callback:
|
||||
_LOGGER.debug("Cancelling scheduled state check")
|
||||
self._check_callback()
|
||||
self._check_callback = None
|
||||
|
||||
@callback
|
||||
def _cancel_cycle_timer(self) -> None:
|
||||
"""Reset cycle timer."""
|
||||
if self._cycle_callback:
|
||||
_LOGGER.debug("Cancelling scheduled shut-off")
|
||||
self._cycle_callback()
|
||||
self._cycle_callback = None
|
||||
|
||||
@callback
|
||||
def _cancel_timers(self) -> None:
|
||||
"""Reset timers."""
|
||||
self._cancel_check_timer()
|
||||
self._cancel_cycle_timer()
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -13,20 +12,16 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
|
||||
from homeassistant.const import CONF_NAME, DEGREE
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_AC_MODE,
|
||||
CONF_COLD_TOLERANCE,
|
||||
CONF_DUR_COOLDOWN,
|
||||
CONF_HEATER,
|
||||
CONF_HOT_TOLERANCE,
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_DUR,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_DUR,
|
||||
CONF_MIN_TEMP,
|
||||
@@ -68,12 +63,6 @@ OPTIONS_SCHEMA = {
|
||||
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector(
|
||||
selector.DurationSelectorConfig(allow_negative=False)
|
||||
),
|
||||
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
|
||||
selector.NumberSelectorConfig(
|
||||
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
|
||||
@@ -101,31 +90,13 @@ CONFIG_SCHEMA = {
|
||||
}
|
||||
|
||||
|
||||
async def _validate_config(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate config."""
|
||||
if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)):
|
||||
min_cycle = timedelta(**user_input[CONF_MIN_DUR])
|
||||
max_cycle = timedelta(**user_input[CONF_MAX_DUR])
|
||||
|
||||
if min_cycle >= max_cycle:
|
||||
raise SchemaFlowError("min_max_runtime")
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
CONFIG_FLOW = {
|
||||
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
|
||||
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
|
||||
}
|
||||
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(
|
||||
vol.Schema(OPTIONS_SCHEMA),
|
||||
validate_user_input=_validate_config,
|
||||
next_step="presets",
|
||||
),
|
||||
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
|
||||
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
|
||||
}
|
||||
|
||||
@@ -133,7 +104,7 @@ OPTIONS_FLOW = {
|
||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config or options flow."""
|
||||
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
@@ -20,8 +20,6 @@ CONF_HEATER = "heater"
|
||||
CONF_HOT_TOLERANCE = "hot_tolerance"
|
||||
CONF_MAX_TEMP = "max_temp"
|
||||
CONF_MIN_DUR = "min_cycle_duration"
|
||||
CONF_MAX_DUR = "max_cycle_duration"
|
||||
CONF_DUR_COOLDOWN = "cycle_cooldown"
|
||||
CONF_MIN_TEMP = "min_temp"
|
||||
CONF_PRESETS = {
|
||||
p: f"{p}_temp"
|
||||
|
||||
@@ -16,13 +16,11 @@
|
||||
"data": {
|
||||
"ac_mode": "Cooling mode",
|
||||
"cold_tolerance": "Cold tolerance",
|
||||
"cycle_cooldown": "Cooldown period after running",
|
||||
"heater": "Actuator switch",
|
||||
"hot_tolerance": "Hot tolerance",
|
||||
"keep_alive": "Keep-alive interval",
|
||||
"max_cycle_duration": "Maximum run time",
|
||||
"max_temp": "Maximum target temperature",
|
||||
"min_cycle_duration": "Minimum run time",
|
||||
"min_cycle_duration": "Minimum cycle duration",
|
||||
"min_temp": "Minimum target temperature",
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"target_sensor": "Temperature sensor"
|
||||
@@ -30,12 +28,10 @@
|
||||
"data_description": {
|
||||
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
|
||||
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
|
||||
"cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.",
|
||||
"heater": "Switch entity used to cool or heat depending on A/C mode.",
|
||||
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
|
||||
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
|
||||
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
|
||||
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
|
||||
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
|
||||
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
|
||||
"target_sensor": "Temperature sensor that reflects the current temperature."
|
||||
},
|
||||
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
|
||||
@@ -44,19 +40,14 @@
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"min_max_runtime": "Minimum run time must be less than the maximum run time."
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
|
||||
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
|
||||
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]",
|
||||
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
|
||||
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
|
||||
@@ -65,11 +56,9 @@
|
||||
"data_description": {
|
||||
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
|
||||
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
|
||||
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]",
|
||||
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
|
||||
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
|
||||
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
|
||||
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
|
||||
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
|
||||
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -7,12 +7,7 @@ import aiohttp
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
OAuth2TokenRequestError,
|
||||
OAuth2TokenRequestReauthError,
|
||||
)
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
@@ -44,11 +39,11 @@ async def async_setup_entry(
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except OAuth2TokenRequestReauthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
except OAuth2TokenRequestError as err:
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if 400 <= err.status < 500:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"OAuth session is not valid, reauth required"
|
||||
) from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["network"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["govee-local-api==2.4.0"]
|
||||
"requirements": ["govee-local-api==2.3.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ rules:
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: data-descriptions missing
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
@@ -23,7 +25,7 @@ rules:
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
@@ -53,7 +55,7 @@ rules:
|
||||
status: todo
|
||||
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
|
||||
@@ -17,20 +17,12 @@
|
||||
"region": "Server region",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The password for your Growatt account.",
|
||||
"region": "The server region that matches your Growatt account location.",
|
||||
"username": "The email address or username for your Growatt account."
|
||||
},
|
||||
"title": "Enter your Growatt login credentials"
|
||||
},
|
||||
"plant": {
|
||||
"data": {
|
||||
"plant_id": "Plant"
|
||||
},
|
||||
"data_description": {
|
||||
"plant_id": "The Growatt plant (solar installation) to integrate."
|
||||
},
|
||||
"title": "Select your plant"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -40,32 +32,22 @@
|
||||
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]",
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
|
||||
"token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]",
|
||||
"username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]"
|
||||
},
|
||||
"description": "Re-enter your credentials to continue using this integration.",
|
||||
"title": "Re-authenticate with Growatt"
|
||||
},
|
||||
"token_auth": {
|
||||
"data": {
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
|
||||
"token": "API token"
|
||||
},
|
||||
"data_description": {
|
||||
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
|
||||
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
|
||||
"token": "API Token"
|
||||
},
|
||||
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"title": "Enter your API token"
|
||||
},
|
||||
"user": {
|
||||
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
|
||||
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
|
||||
"menu_options": {
|
||||
"password_auth": "Username/password",
|
||||
"token_auth": "API token (MIN/TLX only)"
|
||||
"password_auth": "Username & Password",
|
||||
"token_auth": "API Token (MIN/TLX only)"
|
||||
},
|
||||
"title": "Choose authentication method"
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ from functools import partial, wraps
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aiohasupervisor import (
|
||||
AddonNotSupportedError,
|
||||
SupervisorError,
|
||||
SupervisorNotFoundError,
|
||||
)
|
||||
from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
AddonsOptions,
|
||||
AddonState as SupervisorAddonState,
|
||||
@@ -169,7 +165,15 @@ class AddonManager:
|
||||
)
|
||||
|
||||
addon_info = await self._supervisor_client.addons.addon_info(self.addon_slug)
|
||||
return self._async_convert_installed_addon_info(addon_info)
|
||||
addon_state = self.async_get_addon_state(addon_info)
|
||||
return AddonInfo(
|
||||
available=addon_info.available,
|
||||
hostname=addon_info.hostname,
|
||||
options=addon_info.options,
|
||||
state=addon_state,
|
||||
update_available=addon_info.update_available,
|
||||
version=addon_info.version,
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState:
|
||||
@@ -185,20 +189,6 @@ class AddonManager:
|
||||
|
||||
return addon_state
|
||||
|
||||
@callback
|
||||
def _async_convert_installed_addon_info(
|
||||
self, addon_info: InstalledAddonComplete
|
||||
) -> AddonInfo:
|
||||
"""Convert InstalledAddonComplete model to AddonInfo model."""
|
||||
return AddonInfo(
|
||||
available=addon_info.available,
|
||||
hostname=addon_info.hostname,
|
||||
options=addon_info.options,
|
||||
state=self.async_get_addon_state(addon_info),
|
||||
update_available=addon_info.update_available,
|
||||
version=addon_info.version,
|
||||
)
|
||||
|
||||
@api_error(
|
||||
"Failed to set the {addon_name} app options",
|
||||
expected_error_type=SupervisorError,
|
||||
@@ -209,17 +199,21 @@ class AddonManager:
|
||||
self.addon_slug, AddonsOptions(config=config)
|
||||
)
|
||||
|
||||
def _check_addon_available(self, addon_info: AddonInfo) -> None:
|
||||
"""Check if the managed add-on is available."""
|
||||
if not addon_info.available:
|
||||
raise AddonError(f"{self.addon_name} app is not available")
|
||||
|
||||
@api_error(
|
||||
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
|
||||
)
|
||||
async def async_install_addon(self) -> None:
|
||||
"""Install the managed add-on."""
|
||||
try:
|
||||
await self._supervisor_client.store.install_addon(self.addon_slug)
|
||||
except AddonNotSupportedError as err:
|
||||
raise AddonError(
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
self._check_addon_available(addon_info)
|
||||
|
||||
await self._supervisor_client.store.install_addon(self.addon_slug)
|
||||
|
||||
@api_error(
|
||||
"Failed to uninstall the {addon_name} app",
|
||||
@@ -232,29 +226,17 @@ class AddonManager:
|
||||
@api_error("Failed to update the {addon_name} app")
|
||||
async def async_update_addon(self) -> None:
|
||||
"""Update the managed add-on if needed."""
|
||||
try:
|
||||
# Not using async_get_addon_info here because it would make an unnecessary
|
||||
# call to /store/addon/{slug}/info. This will raise if the addon is not
|
||||
# installed so one call to /addon/{slug}/info is all that is needed
|
||||
addon_info = await self._supervisor_client.addons.addon_info(
|
||||
self.addon_slug
|
||||
)
|
||||
except SupervisorNotFoundError:
|
||||
raise AddonError(f"{self.addon_name} app is not installed") from None
|
||||
addon_info = await self.async_get_addon_info()
|
||||
|
||||
self._check_addon_available(addon_info)
|
||||
|
||||
if addon_info.state is AddonState.NOT_INSTALLED:
|
||||
raise AddonError(f"{self.addon_name} app is not installed")
|
||||
|
||||
if not addon_info.update_available:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._supervisor_client.store.addon_availability(self.addon_slug)
|
||||
except AddonNotSupportedError as err:
|
||||
raise AddonError(
|
||||
f"{self.addon_name} app is not available: {err!s}"
|
||||
) from None
|
||||
|
||||
await self.async_create_backup(
|
||||
addon_info=self._async_convert_installed_addon_info(addon_info)
|
||||
)
|
||||
await self.async_create_backup()
|
||||
await self._supervisor_client.store.update_addon(
|
||||
self.addon_slug, StoreAddonUpdate(backup=False)
|
||||
)
|
||||
@@ -284,14 +266,10 @@ class AddonManager:
|
||||
"Failed to create a backup of the {addon_name} app",
|
||||
expected_error_type=SupervisorError,
|
||||
)
|
||||
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
|
||||
async def async_create_backup(self) -> None:
|
||||
"""Create a partial backup of the managed add-on."""
|
||||
if addon_info:
|
||||
addon_version = addon_info.version
|
||||
else:
|
||||
addon_version = (await self.async_get_addon_info()).version
|
||||
|
||||
name = f"addon_{self.addon_slug}_{addon_version}"
|
||||
addon_info = await self.async_get_addon_info()
|
||||
name = f"addon_{self.addon_slug}_{addon_info.version}"
|
||||
|
||||
self._logger.debug("Creating backup: %s", name)
|
||||
await self._supervisor_client.backups.partial_backup(
|
||||
|
||||
@@ -45,7 +45,6 @@ RESPONSE_HEADERS_FILTER = {
|
||||
}
|
||||
|
||||
MIN_COMPRESSED_SIZE = 128
|
||||
MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB
|
||||
MAX_SIMPLE_RESPONSE_SIZE = 4194000
|
||||
|
||||
DISABLED_TIMEOUT = ClientTimeout(total=None)
|
||||
@@ -127,10 +126,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
req_protocols = ()
|
||||
|
||||
ws_server = web.WebSocketResponse(
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
protocols=req_protocols, autoclose=False, autoping=False
|
||||
)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
@@ -153,7 +149,6 @@ class HassIOIngress(HomeAssistantView):
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
@@ -277,6 +281,10 @@
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
|
||||
@@ -38,7 +38,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Provides climate entities for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
HVAC_MODES_PROGRAMS_MAP = {
|
||||
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
}
|
||||
|
||||
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
PRESET_MODES_PROGRAMS_MAP = {
|
||||
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
}
|
||||
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
FAN_MODES_OPTIONS = {
|
||||
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
|
||||
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningEntity(appliance_coordinator)]
|
||||
if (programs := appliance_coordinator.data.programs)
|
||||
and any(
|
||||
program.key in PROGRAMS_HVAC_MODES_MAP
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
for program in programs
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect climate entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
"""Representation of a Home Connect climate entity."""
|
||||
|
||||
# Note: The base class requires this to be set even though this
|
||||
# class doesn't support any temperature related functionality.
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available hvac operation modes."""
|
||||
hvac_modes = [
|
||||
hvac_mode
|
||||
for program in self.appliance.programs
|
||||
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
]
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
return hvac_modes
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
return (
|
||||
[
|
||||
PROGRAMS_PRESET_MODES_MAP[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
]
|
||||
]
|
||||
if any(
|
||||
program.key
|
||||
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
for program in self.appliance.programs
|
||||
)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
features = ClimateEntityFeature(0)
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
):
|
||||
features |= ClimateEntityFeature.FAN_MODE
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_fan_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.async_write_ha_state,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_fan_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the HVAC Mode and preset mode values."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
program_key = cast(ProgramKey, event.value) if event else None
|
||||
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if power_state is not None and power_state.value != BSH_POWER_ON
|
||||
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
and program_key
|
||||
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
self._attr_preset_mode = (
|
||||
PROGRAMS_PRESET_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
option_value = None
|
||||
if event := self.appliance.events.get(
|
||||
EventKey(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
):
|
||||
option_value = event.value
|
||||
return (
|
||||
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes."""
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
):
|
||||
return [
|
||||
fan_mode
|
||||
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
|
||||
if api_value in option_constraints.allowed_values
|
||||
]
|
||||
if option_definition:
|
||||
# Then the constraints or the allowed values are not present
|
||||
# So we stick to the default values
|
||||
return list(FAN_MODES_OPTIONS.keys())
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_STANDBY,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _set_program(self, program_key: ProgramKey) -> None:
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program_key.value,
|
||||
},
|
||||
) from err
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_MODES_OPTIONS[fan_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
@@ -79,29 +79,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
async def async_set_option_with_key(
|
||||
self, option_key: OptionKey, value: Any
|
||||
) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
@@ -118,9 +95,40 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: Any) -> None:
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
"""Set an option for the entity."""
|
||||
await super().async_set_option_with_key(self.bsh_key, value)
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
@@ -11,11 +13,14 @@ from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -171,7 +176,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
@@ -183,14 +188,41 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
@@ -46,12 +46,10 @@ PROGRAM_OPTIONS = {
|
||||
value,
|
||||
)
|
||||
for key, value in {
|
||||
OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All(
|
||||
int, vol.Range(min=0)
|
||||
),
|
||||
OptionKey.BSH_COMMON_DURATION: int,
|
||||
OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
|
||||
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
|
||||
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
|
||||
@@ -62,10 +60,7 @@ PROGRAM_OPTIONS = {
|
||||
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
|
||||
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All(
|
||||
int, vol.Range(min=1, max=100)
|
||||
),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
|
||||
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
|
||||
|
||||
@@ -119,23 +119,6 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"active_clean": "Active clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==5.0.0",
|
||||
"fnv-hash-fast==2.0.0",
|
||||
"fnv-hash-fast==1.6.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
),
|
||||
"started_humidifying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
|
||||
"started_humidifying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
|
||||
@@ -15,34 +15,54 @@ from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_HUMIDITY,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import NumericalDomainSpec
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
|
||||
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
|
||||
CLIMATE_DOMAIN: NumericalDomainSpec(
|
||||
value_source=CLIMATE_ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
HUMIDIFIER_DOMAIN: NumericalDomainSpec(
|
||||
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
),
|
||||
WEATHER_DOMAIN: NumericalDomainSpec(
|
||||
value_source=ATTR_WEATHER_HUMIDITY,
|
||||
),
|
||||
}
|
||||
|
||||
class _HumidityTriggerMixin(EntityTriggerBase):
|
||||
"""Mixin for humidity triggers providing entity filtering and value extraction."""
|
||||
|
||||
_attributes = {
|
||||
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
|
||||
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
SENSOR_DOMAIN: None, # Use state.state
|
||||
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
|
||||
}
|
||||
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
|
||||
or get_device_class_or_undefined(self._hass, entity_id)
|
||||
== SensorDeviceClass.HUMIDITY
|
||||
}
|
||||
|
||||
|
||||
class HumidityChangedTrigger(
|
||||
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
|
||||
):
|
||||
"""Trigger for humidity value changes across multiple domains."""
|
||||
|
||||
|
||||
class HumidityCrossedThresholdTrigger(
|
||||
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for humidity value crossing a threshold across multiple domains."""
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
|
||||
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
HUMIDITY_DOMAIN_SPECS
|
||||
),
|
||||
"changed": HumidityChangedTrigger,
|
||||
"crossed_threshold": HumidityCrossedThresholdTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from .const import LOGGER
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -74,7 +74,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
"""Return the current speed percentage."""
|
||||
device_data = self._device_data
|
||||
|
||||
if device_data.speed_set == FanSpeed.auto_get:
|
||||
if device_data.speed_set == FanSpeed.auto:
|
||||
return None
|
||||
|
||||
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
|
||||
@@ -92,7 +92,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
if device_data.mode_set == FanMode.off:
|
||||
return None
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto_get
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
and device_data.mode_set == FanMode.sensor
|
||||
):
|
||||
return "auto"
|
||||
@@ -111,7 +111,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
infinitely.
|
||||
"""
|
||||
percentage = 25 if percentage == 0 else percentage
|
||||
await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage)
|
||||
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the fan."""
|
||||
@@ -124,10 +124,10 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set preset mode."""
|
||||
await self.async_set_mode_speed(preset_mode=preset_mode)
|
||||
await self.async_set_mode_speed(fan_mode=preset_mode)
|
||||
|
||||
async def async_set_mode_speed(
|
||||
self, preset_mode: str | None = None, percentage: int | None = None
|
||||
self, fan_mode: str | None = None, percentage: int | None = None
|
||||
) -> None:
|
||||
"""Set mode and speed.
|
||||
|
||||
@@ -137,7 +137,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
percentage = self.percentage if percentage is None else percentage
|
||||
percentage = 25 if percentage is None else percentage
|
||||
|
||||
if preset_mode == "auto":
|
||||
if fan_mode == "auto":
|
||||
# auto is a special case with special mode and speed setting
|
||||
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -148,20 +148,21 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
|
||||
return
|
||||
|
||||
# Determine the fan mode
|
||||
if not self.is_on:
|
||||
if fan_mode is not None:
|
||||
# Set to requested fan_mode
|
||||
mode = fan_mode
|
||||
elif not self.is_on:
|
||||
# Default to alternate fan mode if not turned on
|
||||
mode = FanMode.alternate
|
||||
else:
|
||||
# Maintain current mode
|
||||
mode = self._device_data.mode_set
|
||||
|
||||
speed = FanSpeed(
|
||||
str(
|
||||
math.ceil(
|
||||
percentage_to_ranged_value(
|
||||
self._speed_range,
|
||||
percentage,
|
||||
)
|
||||
speed = str(
|
||||
math.ceil(
|
||||
percentage_to_ranged_value(
|
||||
self._speed_range,
|
||||
percentage,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyintelliclima==0.3.1"]
|
||||
"requirements": ["pyintelliclima==0.2.2"]
|
||||
}
|
||||
|
||||
@@ -68,12 +68,12 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
|
||||
|
||||
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto_get
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
and device_data.mode_set == FanMode.sensor
|
||||
):
|
||||
return None
|
||||
|
||||
return INTELLICLIMA_MODE_TO_FAN_MODE.get(device_data.mode_set)
|
||||
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the fan mode."""
|
||||
@@ -83,7 +83,7 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
|
||||
|
||||
# Determine speed: keep current speed if available, otherwise default to sleep
|
||||
if (
|
||||
device_data.speed_set == FanSpeed.auto_get
|
||||
device_data.speed_set == FanSpeed.auto
|
||||
or device_data.mode_set == FanMode.off
|
||||
):
|
||||
speed = FanSpeed.sleep
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Sensor platform for IntelliClima VMC."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyintelliclima.intelliclima_types import IntelliClimaECO
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
|
||||
from .entity import IntelliClimaECOEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class IntelliClimaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a sensor entity."""
|
||||
|
||||
value_fn: Callable[[IntelliClimaECO], int | float | str | None]
|
||||
|
||||
|
||||
INTELLICLIMA_SENSORS: tuple[IntelliClimaSensorEntityDescription, ...] = (
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device_data: float(device_data.tamb),
|
||||
),
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="humidity",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device_data: float(device_data.rh),
|
||||
),
|
||||
IntelliClimaSensorEntityDescription(
|
||||
key="voc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
value_fn=lambda device_data: float(device_data.voc_state),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IntelliClimaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a IntelliClima Sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities: list[IntelliClimaSensor] = [
|
||||
IntelliClimaSensor(
|
||||
coordinator=coordinator, device=ecocomfort2, description=description
|
||||
)
|
||||
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
|
||||
for description in INTELLICLIMA_SENSORS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IntelliClimaSensor(IntelliClimaECOEntity, SensorEntity):
|
||||
"""Extends IntelliClimaEntity with Sensor specific logic."""
|
||||
|
||||
entity_description: IntelliClimaSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IntelliClimaCoordinator,
|
||||
device: IntelliClimaECO,
|
||||
description: IntelliClimaSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Class initializer."""
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
self.entity_description = description
|
||||
|
||||
self._attr_unique_id = f"{device.id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | str | None:
|
||||
"""Use this to get the correct value."""
|
||||
return self.entity_description.value_fn(self._device_data)
|
||||
@@ -6,7 +6,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"no_devices": "No supported IntelliClima devices were found in your account",
|
||||
"no_devices": "No IntelliClima devices found in your account",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["intellifire4py"],
|
||||
"requirements": ["intellifire4py==4.4.0"]
|
||||
"requirements": ["intellifire4py==4.3.1"]
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
|
||||
IntellifireSensorEntityDescription(
|
||||
key="timer_end_timestamp",
|
||||
translation_key="timer_end_timestamp",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=_time_remaining_to_timestamp,
|
||||
),
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.11.1"]
|
||||
"requirements": ["librehardwaremonitor-api==1.10.1"]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ from typing import Any
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
make_entity_numerical_state_crossed_threshold_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
@@ -21,20 +20,28 @@ def _convert_uint8_to_percentage(value: Any) -> float:
|
||||
return (float(value) / 255.0) * 100.0
|
||||
|
||||
|
||||
BRIGHTNESS_DOMAIN_SPECS = {
|
||||
DOMAIN: NumericalDomainSpec(
|
||||
value_source=ATTR_BRIGHTNESS,
|
||||
value_converter=_convert_uint8_to_percentage,
|
||||
),
|
||||
}
|
||||
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for brightness changed."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
|
||||
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
class BrightnessCrossedThresholdTrigger(
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for brightness crossed threshold."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"brightness_changed": make_entity_numerical_state_changed_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"brightness_changed": BrightnessChangedTrigger,
|
||||
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import LitterRobotConfigEntry
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class RobotBinarySensorEntityDescription(
|
||||
|
||||
@@ -14,9 +14,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import LitterRobotConfigEntry
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
from .entity import LitterRobotEntity, _WhiskerEntityT
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -73,7 +71,6 @@ class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):
|
||||
|
||||
entity_description: RobotButtonEntityDescription[_WhiskerEntityT]
|
||||
|
||||
@whisker_command
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self.entity_description.press_fn(self.robot)
|
||||
|
||||
@@ -10,7 +10,7 @@ from pylitterbot import Account
|
||||
from pylitterbot.exceptions import LitterRobotException, LitterRobotLoginException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -21,7 +21,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||
)
|
||||
STEP_REAUTH_RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||
|
||||
|
||||
class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -44,45 +43,24 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user's reauth credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
errors = {}
|
||||
if user_input:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
result, errors = await self._async_validate_and_update_entry(
|
||||
reauth_entry, user_input
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
user_input = user_input | {CONF_USERNAME: self.username}
|
||||
if not (error := await self._async_validate_input(user_input)):
|
||||
await self.async_set_unique_id(self._account_user_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
|
||||
errors["base"] = error
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA,
|
||||
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
|
||||
description_placeholders={CONF_USERNAME: self.username},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow request."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
self.username = reconfigure_entry.data[CONF_USERNAME]
|
||||
|
||||
self._async_abort_entries_match({CONF_USERNAME: self.username})
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input:
|
||||
result, errors = await self._async_validate_and_update_entry(
|
||||
reconfigure_entry, user_input
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=STEP_REAUTH_RECONFIGURE_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: Mapping[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -103,25 +81,6 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _async_validate_and_update_entry(
|
||||
self, entry: ConfigEntry, user_input: dict[str, Any]
|
||||
) -> tuple[ConfigFlowResult | None, dict[str, str]]:
|
||||
"""Validate credentials and update an existing entry if valid."""
|
||||
errors: dict[str, str] = {}
|
||||
full_input: dict[str, Any] = user_input | {CONF_USERNAME: self.username}
|
||||
if not (error := await self._async_validate_input(full_input)):
|
||||
await self.async_set_unique_id(self._account_user_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return (
|
||||
self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates=full_input,
|
||||
),
|
||||
errors,
|
||||
)
|
||||
errors["base"] = error
|
||||
return None, errors
|
||||
|
||||
async def _async_validate_input(self, user_input: Mapping[str, Any]) -> str:
|
||||
"""Validate login credentials."""
|
||||
account = Account(websession=async_get_clientsession(self.hass))
|
||||
|
||||
@@ -46,22 +46,11 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Update all device states from the Litter-Robot API."""
|
||||
try:
|
||||
await self.account.refresh_robots()
|
||||
await self.account.load_pets()
|
||||
for pet in self.account.pets:
|
||||
# Need to fetch weight history for `get_visits_since`
|
||||
await pet.fetch_weight_history()
|
||||
except LitterRobotLoginException as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_credentials"
|
||||
) from ex
|
||||
except LitterRobotException as ex:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
await self.account.refresh_robots()
|
||||
await self.account.load_pets()
|
||||
for pet in self.account.pets:
|
||||
# Need to fetch weight history for `get_visits_since`
|
||||
await pet.fetch_weight_history()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
@@ -74,15 +63,9 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
load_pets=True,
|
||||
)
|
||||
except LitterRobotLoginException as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_credentials"
|
||||
) from ex
|
||||
raise ConfigEntryAuthFailed("Invalid credentials") from ex
|
||||
except LitterRobotException as ex:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": str(ex)},
|
||||
) from ex
|
||||
raise UpdateFailed("Unable to connect to Whisker API") from ex
|
||||
|
||||
def litter_robots(self) -> Generator[LitterRobot]:
|
||||
"""Get Litter-Robots from the account."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user