Compare commits

..

62 Commits

Author SHA1 Message Date
Robert Resch
927a03eca2 Merge branch 'dev' into drop-ignore-missing-annotations 2026-03-16 19:19:47 +01:00
Artur Pragacz
db4af890f4 Use standard syrupy serialisation for registries in homekit controller (#165693) 2026-03-16 18:17:22 +00:00
cdheiser
501c8fecec Bump pylutron to 0.4.0 and maintain switch compatibility (#165592) 2026-03-16 19:13:23 +01:00
Andres Ruiz
03edee1335 Enable support for multiple Waterfurnace devices (#162692)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 19:04:14 +01:00
Nathan Spencer
00b0da7d26 Add auto device removal handling to Whisker (#165709) 2026-03-16 18:01:37 +00:00
J. Nick Koston
bf23fc5887 Fix choppy HomeKit camera audio with SRTP audio proxy (#165185) 2026-03-16 07:36:08 -10:00
Artur Pragacz
6f746c4375 Add common entity_entry_as_dict util to diagnostics (#165692) 2026-03-16 18:16:13 +01:00
Nathan Spencer
e7c3a62569 Add dynamic devices support for Whisker (#165704) 2026-03-16 18:11:10 +01:00
Joost Lekkerkerker
b1578a0c8c Add hassfest check to make sure new integrations have an integration type (#164001) 2026-03-16 18:10:30 +01:00
Martin Ecker
56b4d2c015 Add correct speed fan mapping for Z-Wave GE/Jasco Enbrighten ZWA4013 (#164500) 2026-03-16 17:53:58 +01:00
Erwin Douna
d5ee99c450 Proxmox re-use sanitize UserID (#164303) 2026-03-16 17:50:51 +01:00
hanwg
7d2a305996 Suggest chat_id for subentry flow for Telegram bot (#165515)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 17:33:00 +01:00
Josef Zweck
6945418805 Refactor mold_indicator sensor (#165696) 2026-03-16 17:10:05 +01:00
Ariel Ebersberger
ccecbcb389 Refactor condition helpers (#165662) 2026-03-16 16:57:53 +01:00
epenet
8bb51c0662 Move meteo_france coordinators to separate module (#164558)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 16:27:54 +01:00
Raj Laud
f66edf6b86 Bump victron-ble-ha-parser to 0.6.1 (#165473)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 15:27:06 +00:00
Joost Lekkerkerker
70e469366b Finish test coverage in TRMNL (#165611) 2026-03-16 16:18:20 +01:00
epenet
4a9ba865be Fix HVACMode mappings in Tuya climate (#165691) 2026-03-16 16:15:12 +01:00
Denis Shulyaka
0167182e2e Add support for service tier for OpenAI integration (#165379)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-16 15:38:29 +01:00
Ariel Ebersberger
11411a880d Refactor trigger helpers (#165455)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 15:26:57 +01:00
J. Diego Rodríguez Royo
ce47abe1d3 Add climate entity for air conditioner to Home Connect (#155981)
Co-authored-by: Joostlek <joostlek@outlook.com>
Co-authored-by: emontnemery <erik@montnemery.com>
2026-03-16 15:19:57 +01:00
epenet
b58513c19a Use TuyaCoverAction enum in Tuya cover (#165690) 2026-03-16 15:08:49 +01:00
epenet
4e1dab6d8b Migrate remaining vacuum wrappers to Tuya library (#165688) 2026-03-16 15:06:03 +01:00
epenet
5ae8e1c319 Migrate remaining climate wrappers to Tuya library (#165687) 2026-03-16 15:03:15 +01:00
epenet
17bf6ca591 Migrate remaining alarm control panel wrappers to Tuya library (#165686) 2026-03-16 14:59:10 +01:00
epenet
256d30c38d Migrate remaining fan wrappers to Tuya library (#165685) 2026-03-16 14:56:26 +01:00
Jan Čermák
5d182394c2 Update zizmor to v1.23.1 (#165467) 2026-03-16 14:30:13 +01:00
epenet
011e6863d8 Bump tuya-device-handlers to 0.0.13 (#165684) 2026-03-16 14:11:26 +01:00
Anis Kadri
b902b590b1 Add UniFi Access binary sensors (#165569)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 14:03:46 +01:00
peteS-UK
960666e15b Improve discovery flow for Squeezebox (#153958) 2026-03-16 13:50:33 +01:00
Mike Degatano
1fb59c9f11 Remove code notary related unsupported reasons (#165417)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-16 13:45:58 +01:00
Mike Degatano
332bf95e16 Bump aiohasupervisor to 0.4.1 (#165489)
Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-03-16 13:11:48 +01:00
Joost Lekkerkerker
e35fc8267e Fix typing in nsw_fuel_station (#165679) 2026-03-16 12:41:53 +01:00
Joost Lekkerkerker
f8b4ffc0d7 Fix translation placeholders in Assist pipeline (#165676) 2026-03-16 12:37:47 +01:00
Mike Degatano
003ee5a699 Remove aiohasupervisor from pyproject.toml (#165512) 2026-03-16 11:56:10 +01:00
epenet
c91d805174 Use external library wrapper in Tuya vacuum (#165673) 2026-03-16 11:52:34 +01:00
epenet
c478d19ae3 Use external library wrapper in Tuya climate (#165672) 2026-03-16 11:46:59 +01:00
Samuel Xiao
09169b0f06 Switchbot Cloud: Fixed Circulator Fan on start error (#165241)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-03-16 11:45:21 +01:00
epenet
aa1dbee315 Use external library wrapper in Tuya cover (#165656) 2026-03-16 11:37:18 +01:00
TimL
daf89e5673 Bump Pysmlight to 0.3.0 (#165658) 2026-03-16 11:35:25 +01:00
Joshua Monta
85dc81c147 Update uhoo IQS to silver (#165665) 2026-03-16 11:31:53 +01:00
epenet
5acf24cb53 Use external library wrapper in Tuya alarm control panel (#165671) 2026-03-16 11:30:51 +01:00
Martin Hjelmare
79829a311c Fix emulated_kasa tests for Python 3.14.3 (#165667) 2026-03-16 11:19:06 +01:00
Martin Hjelmare
ce2c62ae28 Fix numato tests for Python 3.14.3 (#165669) 2026-03-16 11:17:29 +01:00
Martin Hjelmare
1cda3f47d6 Fix valve tests for Python 3.14.3 (#165668) 2026-03-16 11:16:27 +01:00
Nathan Spencer
e254716615 Remove deprecated entity creation code for Litter-Robot 4 devices (#165636) 2026-03-16 10:40:31 +01:00
epenet
1d410f4cbd Use external library wrapper in Tuya humidifer (#165654) 2026-03-16 10:39:54 +01:00
epenet
6616793e2b Use external library wrapper in Tuya light (#165653) 2026-03-16 10:39:43 +01:00
Joost Lekkerkerker
6766961327 Finish TRMNL docs (#165612) 2026-03-16 10:38:11 +01:00
Denis Shulyaka
dd6fc11d28 Bump python-telegram-bot to 22.6 (#165508)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-16 10:34:54 +01:00
Simone Chemelli
cb5b8b212c Bump aiocomelit to 2.0.1 (#165663) 2026-03-16 10:32:55 +01:00
epenet
66b96d096e Use external library wrapper in Tuya event (#165655) 2026-03-16 10:32:31 +01:00
epenet
e86160de36 Use external library wrapper in Tuya fan (#165464) 2026-03-16 10:24:00 +01:00
Simone Chemelli
7617007edd Update IQS to silver for Fritz (#162280) 2026-03-16 10:19:35 +01:00
epenet
3e065b31b3 Simplify Prana entity descriptions (#165660)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-16 10:12:16 +01:00
Simone Chemelli
5f909a6f3a Fix wifi switch status and add 100% coverage for Fritz (#164696) 2026-03-16 10:05:42 +01:00
Jan Bouwhuis
6117a20ec6 Fix MQTT device tracker overrides via JSON state attributes without reset (#165529) 2026-03-16 10:03:35 +01:00
Simone Chemelli
93bc05bb3f Fix switch set for Vodafone Station (#165273) 2026-03-16 10:00:52 +01:00
Thomas Kadauke
e7397ccaa7 fix: Increase WebSocket message size limit to 16MB in Hass.io ingress proxy (#164442)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 09:48:06 +01:00
Joshua Monta
91a43873a2 feat: implement reauthentication requirement (#165641) 2026-03-16 09:03:01 +01:00
Ludovic BOUÉ
469e06fb8c Add Matter certified Silabs fan example to fixtures (#165622) 2026-03-16 09:02:23 +01:00
epenet
70ec51bcbf Drop ignore-missing-annotations from pylint 2026-02-23 16:51:00 +01:00
177 changed files with 11462 additions and 8598 deletions

View File

@@ -72,7 +72,7 @@ jobs:
- name: Download Translations
run: python3 -m script.translations download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
- name: Archive translations
shell: bash

View File

@@ -709,7 +709,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y homeassistant
pylint homeassistant
- name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -718,7 +718,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint-tests:
name: Check pylint on tests
@@ -1400,7 +1400,7 @@ jobs:
with:
fail_ci_if_error: true
flags: full-suite
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
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 }}
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
upload-test-results:
name: Upload test results to Codecov

View File

@@ -58,8 +58,8 @@ jobs:
# v1.7.0
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
with:
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
# The 90 day stale policy for issues
# Used for:

View File

@@ -33,6 +33,6 @@ jobs:
- name: Upload Translations
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
run: |
python3 -m script.translations upload

View File

@@ -142,7 +142,7 @@ jobs:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
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 }}
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
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

View File

@@ -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.22.0
rev: v1.23.1
hooks:
- id: zizmor
args:

View File

@@ -78,19 +78,13 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
index: int = 0,
) -> None:
"""Initialize a pipeline selector."""
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},
)
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)},
)
self._domain = domain
self._unique_id_prefix = unique_id_prefix

View File

@@ -7,11 +7,17 @@
},
"select": {
"pipeline": {
"name": "Assistant{index}",
"name": "Assistant",
"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": {

View File

@@ -128,7 +128,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"person",
"siren",
"switch",

View File

@@ -1,11 +1,8 @@
"""Provides conditions for climates."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import (
Condition,
make_entity_state_attribute_condition,
make_entity_state_condition,
)
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
@@ -22,14 +19,14 @@ CONDITIONS: dict[str, type[Condition]] = {
HVACMode.HEAT_COOL,
},
),
"is_cooling": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
"is_cooling": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
),
"is_drying": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"is_heating": make_entity_state_attribute_condition(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
}

View File

@@ -13,7 +13,6 @@ from homeassistant.helpers.trigger import (
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -47,11 +46,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
"started_cooling": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
),
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
@@ -80,8 +79,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
HVACMode.HEAT_COOL,
},
),
"started_heating": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
"started_heating": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "platinum",
"requirements": ["aiocomelit==2.0.0"]
"requirements": ["aiocomelit==2.0.1"]
}

View File

@@ -38,9 +38,9 @@ from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import format_unserializable_data
from .const import DOMAIN, REDACTED, DiagnosticsSubType, DiagnosticsType
from .util import async_redact_data
from .util import async_redact_data, entity_entry_as_dict
__all__ = ["REDACTED", "async_redact_data"]
__all__ = ["REDACTED", "async_redact_data", "entity_entry_as_dict"]
_LOGGER = logging.getLogger(__name__)

View File

@@ -5,7 +5,10 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping
from typing import Any, cast, overload
import attr
from homeassistant.core import callback
from homeassistant.helpers.entity_registry import RegistryEntry
from .const import REDACTED
@@ -42,3 +45,16 @@ def async_redact_data[_T](data: _T, to_redact: Iterable[Any]) -> _T:
redacted[key] = [async_redact_data(item, to_redact) for item in value]
return cast(_T, redacted)
def _entity_entry_filter(a: attr.Attribute, _: Any) -> bool:
return a.name != "_cache"
@callback
def entity_entry_as_dict(entry: RegistryEntry) -> dict[str, Any]:
"""Convert an entity registry entry to a dict for diagnostics.
This excludes internal fields that should not be exposed in diagnostics.
"""
return attr.asdict(entry, filter=_entity_entry_filter)

View File

@@ -11,7 +11,7 @@ from attr import asdict
from pyenphase.envoy import Envoy
from pyenphase.exceptions import EnvoyError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
@@ -111,8 +111,7 @@ async def async_get_config_entry_diagnostics(
if state := hass.states.get(entity.entity_id):
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entity_dict = asdict(entity)
entity_dict.pop("_cache", None)
entity_dict = entity_entry_as_dict(entity)
entities.append({"entity": entity_dict, "state": state_dict})
device_dict = asdict(device)
device_dict.pop("_cache", None)

View File

@@ -123,19 +123,13 @@ class EsphomeAssistSatelliteWakeWordSelect(
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
"""Initialize a wake word selector."""
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},
)
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)},
)
EsphomeAssistEntity.__init__(self, entry_data)

View File

@@ -107,6 +107,12 @@
"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": {
@@ -116,11 +122,18 @@
}
},
"wake_word": {
"name": "Wake word{index}",
"name": "Wake word",
"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%]"
}
}
}
},

View File

@@ -8,7 +8,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["fritzconnection"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
"ssdp": [
{

View File

@@ -29,9 +29,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
comment: we are close to the goal of 95%
test-coverage: done
# Gold
devices: done

View File

@@ -133,26 +133,20 @@ async def _async_wifi_entities_list(
]
)
_LOGGER.debug("WiFi networks count: %s", wifi_count)
networks: dict = {}
networks: dict[int, dict[str, Any]] = {}
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] = {
"ssid": network_info["NewSSID"],
"bssid": network_info["NewBSSID"],
"standard": network_info["NewStandard"],
"enabled": network_info["NewEnable"],
"status": network_info["NewStatus"],
}
networks[i] = network_info
for i, network in networks.copy().items():
networks[i]["switch_name"] = network["ssid"]
networks[i]["switch_name"] = network["NewSSID"]
if (
len(
[
j
for j, n in networks.items()
if slugify(n["ssid"]) == slugify(network["ssid"])
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
]
)
> 1
@@ -434,13 +428,11 @@ 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) -> bool:
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
resp = await self._avm_wrapper.async_add_port_mapping(
await self._avm_wrapper.async_add_port_mapping(
self.connection_type, self.port_mapping
)
return bool(resp is not None)
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
@@ -525,12 +517,11 @@ 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) -> bool:
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
"""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):
@@ -541,10 +532,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
avm_wrapper: AvmWrapper,
device_friendly_name: str,
network_num: int,
network_data: dict,
network_data: dict[str, Any],
) -> None:
"""Init Fritz Wifi switch."""
self._avm_wrapper = avm_wrapper
self._wifi_info = network_data
self._attributes = {}
self._attr_entity_category = EntityCategory.CONFIG
@@ -560,7 +552,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["enabled"],
init_state=network_data["NewEnable"],
)
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
@@ -587,7 +579,9 @@ 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)

View File

@@ -6,6 +6,7 @@ from typing import Any
from attr import asdict
from homeassistant.components.diagnostics import entity_entry_as_dict
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -44,7 +45,9 @@ async def async_get_config_entry_diagnostics(
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entities.append({"entry": asdict(entity_entry), "state": state_dict})
entities.append(
{"entry": entity_entry_as_dict(entity_entry), "state": state_dict}
)
devices.append({"device": asdict(device), "entities": entities})

View File

@@ -45,6 +45,7 @@ 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)
@@ -126,7 +127,10 @@ class HassIOIngress(HomeAssistantView):
req_protocols = ()
ws_server = web.WebSocketResponse(
protocols=req_protocols, autoclose=False, autoping=False
protocols=req_protocols,
autoclose=False,
autoping=False,
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
)
await ws_server.prepare(request)
@@ -149,6 +153,7 @@ 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(

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.3.3"],
"requirements": ["aiohasupervisor==0.4.1"],
"single_config_entry": true
}

View File

@@ -225,10 +225,6 @@
"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"
@@ -281,10 +277,6 @@
"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"

View File

@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.LIGHT,
Platform.NUMBER,

View File

@@ -0,0 +1,325 @@
"""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
)

View File

@@ -79,6 +79,29 @@ 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."""
@@ -95,40 +118,9 @@ class HomeConnectOptionEntity(HomeConnectEntity):
return event.value
return None
async def async_set_option(self, value: str | float | bool) -> None:
async def async_set_option(self, 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=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
await super().async_set_option_with_key(self.bsh_key, value)
@property
def bsh_key(self) -> OptionKey:

View File

@@ -1,11 +1,9 @@
"""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,
@@ -13,14 +11,11 @@ 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__)
@@ -176,7 +171,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_set_option(
await super().async_set_option_with_key(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
percentage,
)
@@ -188,41 +183,14 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target fan mode."""
await self._async_set_option(
await super().async_set_option_with_key(
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."""

View File

@@ -119,6 +119,23 @@
"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": {

View File

@@ -11,6 +11,7 @@
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==2.0.0",
"homekit-audio-proxy==1.2.1",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -6,6 +6,7 @@ import logging
from typing import Any
from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg
from homekit_audio_proxy import AudioProxy
from pyhap.camera import (
VIDEO_CODEC_PARAM_LEVEL_TYPES,
VIDEO_CODEC_PARAM_PROFILE_ID_TYPES,
@@ -89,11 +90,10 @@ AUDIO_OUTPUT = (
"{a_application}"
"-ac 1 -ar {a_sample_rate}k "
"-b:a {a_max_bitrate}k -bufsize {a_bufsize}k "
"{a_frame_duration}"
"-payload_type 110 "
"-ssrc {a_ssrc} -f rtp "
"-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} "
"srtp://{address}:{a_port}?rtcpport={a_port}&"
"localrtpport={a_port}&pkt_size={a_pkt_size}"
"rtp://127.0.0.1:{a_proxy_port}?pkt_size={a_pkt_size}"
)
SLOW_RESOLUTIONS = [
@@ -120,6 +120,7 @@ FFMPEG_WATCH_INTERVAL = timedelta(seconds=5)
FFMPEG_LOGGER = "ffmpeg_logger"
FFMPEG_WATCHER = "ffmpeg_watcher"
FFMPEG_PID = "ffmpeg_pid"
AUDIO_PROXY = "audio_proxy"
SESSION_ID = "session_id"
CONFIG_DEFAULTS = {
@@ -339,8 +340,33 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
+ " "
)
audio_application = ""
audio_frame_duration = ""
if self.config[CONF_AUDIO_CODEC] == "libopus":
audio_application = "-application lowdelay "
audio_frame_duration = (
f"-frame_duration {stream_config.get('a_packet_time', 20)} "
)
# Start audio proxy to convert Opus RTP timestamps from 48kHz
# (FFmpeg's hardcoded Opus RTP clock rate per RFC 7587) to the
# sample rate negotiated by HomeKit (typically 16kHz).
# a_sample_rate is in kHz (e.g. 16 for 16000 Hz) from pyhap TLV.
audio_proxy: AudioProxy | None = None
if self.config[CONF_SUPPORT_AUDIO]:
audio_proxy = AudioProxy(
dest_addr=stream_config["address"],
dest_port=stream_config["a_port"],
srtp_key_b64=stream_config["a_srtp_key"],
target_clock_rate=stream_config["a_sample_rate"] * 1000,
)
await audio_proxy.async_start()
if not audio_proxy.local_port:
_LOGGER.error(
"[%s] Audio proxy failed to start",
self.display_name,
)
await audio_proxy.async_stop()
audio_proxy = None
output_vars = stream_config.copy()
output_vars.update(
{
@@ -354,6 +380,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
"a_pkt_size": self.config[CONF_AUDIO_PACKET_SIZE],
"a_encoder": self.config[CONF_AUDIO_CODEC],
"a_application": audio_application,
"a_frame_duration": audio_frame_duration,
"a_proxy_port": audio_proxy.local_port if audio_proxy else 0,
}
)
output = VIDEO_OUTPUT.format(**output_vars)
@@ -371,6 +399,8 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
)
if not opened:
_LOGGER.error("Failed to open ffmpeg stream")
if audio_proxy:
await audio_proxy.async_stop()
return False
_LOGGER.debug(
@@ -381,6 +411,7 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
session_info["stream"] = stream
session_info[FFMPEG_PID] = stream.process.pid
session_info[AUDIO_PROXY] = audio_proxy
stderr_reader = await stream.get_reader(source=FFMPEG_STDERR)
@@ -441,6 +472,9 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
async def stop_stream(self, session_info: dict[str, Any]) -> None:
"""Stop the stream for the given ``session_id``."""
session_id = session_info["id"]
if proxy := session_info.pop(AUDIO_PROXY, None):
await proxy.async_stop()
if not (stream := session_info.get("stream")):
_LOGGER.debug("No stream for session ID %s", session_id)
return

View File

@@ -2,22 +2,19 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import (
Condition,
make_entity_state_attribute_condition,
make_entity_state_condition,
)
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_drying": make_entity_state_attribute_condition(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),
"is_humidifying": make_entity_state_attribute_condition(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
"is_humidifying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
}

View File

@@ -2,20 +2,17 @@
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
)
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),
"started_humidifying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
"started_humidifying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),

View File

@@ -18,9 +18,9 @@ from homeassistant.components.weather import (
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,
)
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
@@ -38,24 +38,11 @@ HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
),
}
class HumidityChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for humidity value changes across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
class HumidityCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
"changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS
),
}

View File

@@ -7,7 +7,7 @@ from typing import Any
import attr
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.components.diagnostics import async_redact_data, entity_entry_as_dict
from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -94,7 +94,7 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str,
state_dict = dict(state.as_dict())
state_dict.pop("context", None)
entity = attr.asdict(entity_entry)
entity = entity_entry_as_dict(entity_entry)
entity["state"] = state_dict
entities.append(entity)

View File

@@ -6,9 +6,9 @@ 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,
)
@@ -28,24 +28,13 @@ BRIGHTNESS_DOMAIN_SPECS = {
),
}
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
class BrightnessCrossedThresholdTrigger(
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for brightness crossed threshold."""
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": BrightnessChangedTrigger,
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
"brightness_changed": make_entity_numerical_state_changed_trigger(
BRIGHTNESS_DOMAIN_SPECS
),
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BRIGHTNESS_DOMAIN_SPECS
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}

View File

@@ -78,15 +78,27 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot binary sensors using config entry."""
coordinator = entry.runtime_data
async_add_entities(
LitterRobotBinarySensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotBinarySensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in BINARY_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class LitterRobotBinarySensorEntity(

View File

@@ -58,14 +58,26 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
async_add_entities(
LitterRobotButtonEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, description in ROBOT_BUTTON_MAP.items()
if isinstance(robot, robot_type)
)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotButtonEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, description in ROBOT_BUTTON_MAP.items()
if isinstance(robot, robot_type)
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):

View File

@@ -13,6 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -43,11 +44,12 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
)
self.account = Account(websession=async_get_clientsession(hass))
self.previous_members: set[str] = set()
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_robots(subscribe_for_updates=True)
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
@@ -63,6 +65,22 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
translation_placeholders={"error": str(ex)},
) from ex
current_members = {robot.serial for robot in self.account.robots} | {
pet.id for pet in self.account.pets
}
if stale_members := self.previous_members - current_members:
device_registry = dr.async_get(self.hass)
for device_id in stale_members:
device = device_registry.async_get_device(
identifiers={(DOMAIN, device_id)}
)
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_members = current_members
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:

View File

@@ -52,7 +52,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -64,12 +64,7 @@ rules:
status: done
comment: |
This integration doesn't have any cases where raising an issue is needed
stale-devices:
status: todo
comment: |
Currently handled via async_remove_config_entry_device,
but we should be able to remove devices automatically
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done

View File

@@ -120,15 +120,27 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot selects using config entry."""
coordinator = entry.runtime_data
async_add_entities(
LitterRobotSelectEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, descriptions in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
for description in descriptions
)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotSelectEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, descriptions in ROBOT_SELECT_MAP.items()
if isinstance(robot, robot_type)
for description in descriptions
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class LitterRobotSelectEntity(

View File

@@ -232,23 +232,47 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot sensors using config entry."""
coordinator = entry.runtime_data
entities: list[LitterRobotSensorEntity] = [
LitterRobotSensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
]
entities.extend(
LitterRobotSensorEntity(
robot=pet, coordinator=coordinator, description=description
)
for pet in coordinator.account.pets
for description in PET_SENSORS
)
async_add_entities(entities)
known_robots: set[str] = set()
known_pets: set[str] = set()
def _check_robots_and_pets() -> None:
entities: list[LitterRobotSensorEntity] = []
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
entities.extend(
LitterRobotSensorEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in ROBOT_SENSOR_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
all_pets = coordinator.account.pets
current_pets = {pet.id for pet in all_pets}
new_pets = current_pets - known_pets
if new_pets:
known_pets.update(new_pets)
entities.extend(
LitterRobotSensorEntity(
robot=pet, coordinator=coordinator, description=description
)
for pet in all_pets
if pet.id in new_pets
for description in PET_SENSORS
)
if entities:
async_add_entities(entities)
_check_robots_and_pets()
entry.async_on_unload(coordinator.async_add_listener(_check_robots_and_pets))
class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity):

View File

@@ -218,12 +218,6 @@
"message": "Invalid credentials. Please check your username and password, then try again"
}
},
"issues": {
"deprecated_entity": {
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",
"title": "{name} is deprecated"
}
},
"services": {
"set_sleep_mode": {
"description": "Sets the sleep mode and start time.",

View File

@@ -6,24 +6,13 @@ from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Generic
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
@@ -77,54 +66,27 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot switches using config entry."""
coordinator = entry.runtime_data
entities = [
RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description)
for robot in coordinator.account.robots
for robot_type, entity_descriptions in SWITCH_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
]
known_robots: set[str] = set()
ent_reg = er.async_get(hass)
def add_deprecated_entity(
robot: LitterRobot4,
description: RobotSwitchEntityDescription,
entity_cls: type[RobotSwitchEntity],
) -> None:
"""Add deprecated entities."""
unique_id = f"{robot.serial}-{description.key}"
if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id):
entity_entry = ent_reg.async_get(entity_id)
if entity_entry and entity_entry.disabled:
ent_reg.async_remove(entity_id)
async_delete_issue(
hass,
DOMAIN,
f"deprecated_entity_{unique_id}",
)
elif entity_entry:
entities.append(entity_cls(robot, coordinator, description))
async_create_issue(
hass,
DOMAIN,
f"deprecated_entity_{unique_id}",
breaks_in_ha_version="2026.4.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_entity",
translation_placeholders={
"name": f"{robot.name} {entity_entry.name or entity_entry.original_name}",
"entity": entity_id,
},
def _check_robots() -> None:
all_robots = coordinator.account.robots
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
RobotSwitchEntity(
robot=robot, coordinator=coordinator, description=description
)
for robot in all_robots
if robot.serial in new_robots
for robot_type, entity_descriptions in SWITCH_MAP.items()
if isinstance(robot, robot_type)
for description in entity_descriptions
)
for robot in coordinator.account.get_robots(LitterRobot4):
add_deprecated_entity(
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
)
async_add_entities(entities)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):

View File

@@ -55,15 +55,27 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
async_add_entities(
LitterRobotTimeEntity(
robot=robot,
coordinator=coordinator,
description=LITTER_ROBOT_3_SLEEP_START,
)
for robot in coordinator.litter_robots()
if isinstance(robot, LitterRobot3)
)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotTimeEntity(
robot=robot,
coordinator=coordinator,
description=LITTER_ROBOT_3_SLEEP_START,
)
for robot in all_robots
if robot.serial in new_robots
if isinstance(robot, LitterRobot3)
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):

View File

@@ -39,14 +39,28 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot update platform."""
coordinator = entry.runtime_data
entities = (
RobotUpdateEntity(
robot=robot, coordinator=coordinator, description=FIRMWARE_UPDATE_ENTITY
)
for robot in coordinator.litter_robots()
if isinstance(robot, LitterRobot4)
)
async_add_entities(entities, True)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
entities = (
RobotUpdateEntity(
robot=robot,
coordinator=coordinator,
description=FIRMWARE_UPDATE_ENTITY,
)
for robot in all_robots
if robot.serial in new_robots
if isinstance(robot, LitterRobot4)
)
async_add_entities(entities, True)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):

View File

@@ -48,12 +48,24 @@ async def async_setup_entry(
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
async_add_entities(
LitterRobotCleaner(
robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY
)
for robot in coordinator.litter_robots()
)
known_robots: set[str] = set()
def _check_robots() -> None:
all_robots = list(coordinator.litter_robots())
current_robots = {robot.serial for robot in all_robots}
new_robots = current_robots - known_robots
if new_robots:
known_robots.update(new_robots)
async_add_entities(
LitterRobotCleaner(
robot=robot, coordinator=coordinator, description=LITTER_BOX_ENTITY
)
for robot in all_robots
if robot.serial in new_robots
)
_check_robots()
entry.async_on_unload(coordinator.async_add_listener(_check_robots))
class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pylutron"],
"requirements": ["pylutron==0.3.0"],
"requirements": ["pylutron==0.4.0"],
"single_config_entry": true
}

View File

@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the LED on."""
self._lutron_device.state = True
self._lutron_device.state = Led.LED_ON
def turn_off(self, **kwargs: Any) -> None:
"""Turn the LED off."""
self._lutron_device.state = False
self._lutron_device.state = Led.LED_OFF
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
@@ -108,4 +108,4 @@ class LutronLed(LutronKeypad, SwitchEntity):
def _update_attrs(self) -> None:
"""Update the state attributes."""
self._attr_is_on = self._lutron_device.last_state
self._attr_is_on = self._lutron_device.last_state != Led.LED_OFF

View File

@@ -1,72 +1,38 @@
"""Support for Meteo-France weather data."""
from datetime import timedelta
import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from requests import RequestException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_CITY,
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string})
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up an Meteo-France account from a config entry."""
"""Set up a Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = MeteoFranceClient()
latitude = entry.data[CONF_LATITUDE]
longitude = entry.data[CONF_LONGITUDE]
async def _async_update_data_forecast_forecast() -> Forecast:
"""Fetch data from API endpoint."""
return await hass.async_add_executor_job(
client.get_forecast, latitude, longitude
)
async def _async_update_data_rain() -> Rain:
"""Fetch data from API endpoint."""
return await hass.async_add_executor_job(client.get_rain, latitude, longitude)
async def _async_update_data_alert() -> CurrentPhenomenons:
"""Fetch data from API endpoint."""
assert isinstance(department, str)
return await hass.async_add_executor_job(
client.get_warning_current_phenomenons, department, 0, True
)
coordinator_forecast = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"Météo-France forecast for city {entry.title}",
config_entry=entry,
update_method=_async_update_data_forecast_forecast,
update_interval=SCAN_INTERVAL,
)
coordinator_forecast = MeteoFranceForecastUpdateCoordinator(hass, entry, client)
coordinator_rain = None
coordinator_alert = None
@@ -77,14 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady
# Check rain forecast.
coordinator_rain = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"Météo-France rain for city {entry.title}",
config_entry=entry,
update_method=_async_update_data_rain,
update_interval=SCAN_INTERVAL_RAIN,
)
coordinator_rain = MeteoFranceRainUpdateCoordinator(hass, entry, client)
try:
await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001
except RequestException:
@@ -101,13 +60,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
if department is not None and is_valid_warning_department(department):
if not hass.data[DOMAIN].get(department):
coordinator_alert = DataUpdateCoordinator(
coordinator_alert = MeteoFranceAlertUpdateCoordinator(
hass,
_LOGGER,
name=f"Météo-France alert for department {department}",
config_entry=entry,
update_method=_async_update_data_alert,
update_interval=SCAN_INTERVAL,
entry,
client,
department,
)
await coordinator_alert.async_refresh()

View File

@@ -0,0 +1,107 @@
"""Support for Meteo-France weather data."""
from datetime import timedelta
import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
"""Coordinator for Meteo-France forecast data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France forecast for city {entry.title}",
config_entry=entry,
update_interval=SCAN_INTERVAL,
)
self._client = client
self._latitude = entry.data[CONF_LATITUDE]
self._longitude = entry.data[CONF_LONGITUDE]
async def _async_update_data(self) -> Forecast:
"""Get data from Meteo-France forecast."""
return await self.hass.async_add_executor_job(
self._client.get_forecast, self._latitude, self._longitude
)
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
"""Coordinator for Meteo-France rain data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France rain for city {entry.title}",
config_entry=entry,
update_interval=SCAN_INTERVAL_RAIN,
)
self._client = client
self._latitude = entry.data[CONF_LATITUDE]
self._longitude = entry.data[CONF_LONGITUDE]
async def _async_update_data(self) -> Rain:
"""Get data from Meteo-France rain."""
return await self.hass.async_add_executor_job(
self._client.get_rain, self._latitude, self._longitude
)
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
"""Coordinator for Meteo-France alert data."""
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: MeteoFranceClient,
department: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"Météo-France alert for department {department}",
config_entry=entry,
update_interval=SCAN_INTERVAL,
)
self._client = client
self._department = department
async def _async_update_data(self) -> CurrentPhenomenons:
"""Get data from Meteo-France alert."""
return await self.hass.async_add_executor_job(
self._client.get_warning_current_phenomenons, self._department, 0, True
)

View File

@@ -48,6 +48,11 @@ from .const import (
MANUFACTURER,
MODEL,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@dataclass(frozen=True, kw_only=True)
@@ -188,9 +193,13 @@ async def async_setup_entry(
) -> None:
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN)
coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get(
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
COORDINATOR_FORECAST
]
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
COORDINATOR_RAIN
)
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
COORDINATOR_ALERT
)
@@ -316,7 +325,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]):
def __init__(
self,
coordinator: DataUpdateCoordinator[CurrentPhenomenons],
coordinator: MeteoFranceAlertUpdateCoordinator,
description: MeteoFranceSensorEntityDescription,
) -> None:
"""Initialize the Meteo-France sensor."""

View File

@@ -3,8 +3,6 @@
import logging
import time
from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_SUNNY,
@@ -31,10 +29,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import (
@@ -47,6 +42,7 @@ from .const import (
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceForecastUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +62,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][COORDINATOR_FORECAST]
@@ -87,7 +83,7 @@ async def async_setup_entry(
class MeteoFranceWeather(
CoordinatorEntity[DataUpdateCoordinator[MeteoFranceForecast]], WeatherEntity
CoordinatorEntity[MeteoFranceForecastUpdateCoordinator], WeatherEntity
):
"""Representation of a weather condition."""
@@ -101,7 +97,7 @@ class MeteoFranceWeather(
)
def __init__(
self, coordinator: DataUpdateCoordinator[MeteoFranceForecast], mode: str
self, coordinator: MeteoFranceForecastUpdateCoordinator, mode: str
) -> None:
"""Initialise the platform with a data instance and station name."""
super().__init__(coordinator)

View File

@@ -156,17 +156,15 @@ class MoldIndicator(SensorEntity):
"""Initialize the sensor."""
self._attr_name = name
self._attr_unique_id = unique_id
self._indoor_temp_sensor = indoor_temp_sensor
self._indoor_humidity_sensor = indoor_humidity_sensor
self._outdoor_temp_sensor = outdoor_temp_sensor
self._entities = {
CONF_INDOOR_TEMP: indoor_temp_sensor,
CONF_OUTDOOR_TEMP: outdoor_temp_sensor,
CONF_INDOOR_HUMIDITY: indoor_humidity_sensor,
}
self._calib_factor = calib_factor
self._is_metric = is_metric
self._attr_available = False
self._entities = {
indoor_temp_sensor,
indoor_humidity_sensor,
outdoor_temp_sensor,
}
self._dewpoint: float | None = None
self._indoor_temp: float | None = None
self._outdoor_temp: float | None = None
@@ -186,12 +184,7 @@ class MoldIndicator(SensorEntity):
) -> CALLBACK_TYPE:
"""Render a preview."""
# Abort early if there is no source entity_id's or calibration factor
if (
not self._outdoor_temp_sensor
or not self._indoor_temp_sensor
or not self._indoor_humidity_sensor
or not self._calib_factor
):
if not all((*self._entities.values(), self._calib_factor)):
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
@@ -241,22 +234,24 @@ class MoldIndicator(SensorEntity):
_LOGGER.debug("Startup for %s", self.entity_id)
async_track_state_change_event(
self.hass, list(self._entities), mold_indicator_sensors_state_listener
self.hass,
list(self._entities.values()),
mold_indicator_sensors_state_listener,
)
# Read initial state
indoor_temp = self.hass.states.get(self._indoor_temp_sensor)
outdoor_temp = self.hass.states.get(self._outdoor_temp_sensor)
indoor_hum = self.hass.states.get(self._indoor_humidity_sensor)
indoor_temp = self.hass.states.get(self._entities[CONF_INDOOR_TEMP])
outdoor_temp = self.hass.states.get(self._entities[CONF_OUTDOOR_TEMP])
indoor_hum = self.hass.states.get(self._entities[CONF_INDOOR_HUMIDITY])
schedule_update = self._update_sensor(
self._indoor_temp_sensor, None, indoor_temp
self._entities[CONF_INDOOR_TEMP], None, indoor_temp
)
schedule_update = (
False
if not self._update_sensor(
self._outdoor_temp_sensor, None, outdoor_temp
self._entities[CONF_OUTDOOR_TEMP], None, outdoor_temp
)
else schedule_update
)
@@ -264,7 +259,7 @@ class MoldIndicator(SensorEntity):
schedule_update = (
False
if not self._update_sensor(
self._indoor_humidity_sensor, None, indoor_hum
self._entities[CONF_INDOOR_HUMIDITY], None, indoor_hum
)
else schedule_update
)
@@ -299,92 +294,87 @@ class MoldIndicator(SensorEntity):
if old_state is None and new_state.state == STATE_UNKNOWN:
return False
if entity == self._indoor_temp_sensor:
self._indoor_temp = self._update_temp_sensor(new_state)
elif entity == self._outdoor_temp_sensor:
self._outdoor_temp = self._update_temp_sensor(new_state)
elif entity == self._indoor_humidity_sensor:
self._indoor_hum = self._update_hum_sensor(new_state)
if entity == self._entities[CONF_INDOOR_TEMP]:
self._indoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_OUTDOOR_TEMP]:
self._outdoor_temp = self._get_temperature_from_state(new_state)
elif entity == self._entities[CONF_INDOOR_HUMIDITY]:
self._indoor_hum = self._get_humidity_from_state(new_state)
return True
@staticmethod
def _update_temp_sensor(state: State) -> float | None:
"""Parse temperature sensor value."""
_LOGGER.debug("Updating temp sensor with value %s", state.state)
def _get_value_from_state(
self,
state: State | None,
validator: Callable[[float, str | None], float | None],
) -> float | None:
"""Get and validate a sensor value from state."""
if state is None:
return None
# Return an error if the sensor change its state to Unknown.
if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
_LOGGER.debug(
"Unable to parse temperature sensor %s with state: %s",
"Unable to get sensor %s, state: %s",
state.entity_id,
state.state,
)
return None
if (temp := util.convert(state.state, float)) is None:
_LOGGER.error(
"Unable to parse temperature sensor %s with state: %s",
state.entity_id,
state.state,
)
return None
# convert to celsius if necessary
if (
unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
) in UnitOfTemperature:
return TemperatureConverter.convert(temp, unit, UnitOfTemperature.CELSIUS)
_LOGGER.error(
"Temp sensor %s has unsupported unit: %s (allowed: %s, %s)",
state.entity_id,
unit,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
return None
@staticmethod
def _update_hum_sensor(state: State) -> float | None:
"""Parse humidity sensor value."""
_LOGGER.debug("Updating humidity sensor with value %s", state.state)
# Return an error if the sensor change its state to Unknown.
if state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
if (value := util.convert(state.state, float)) is None:
_LOGGER.debug(
"Unable to parse humidity sensor %s, state: %s",
"Unable to parse sensor value %s, state: %s to float",
state.entity_id,
state.state,
)
return None
if (hum := util.convert(state.state, float)) is None:
_LOGGER.error(
"Unable to parse humidity sensor %s, state: %s",
state.entity_id,
state.state,
)
return None
return validator(value, state.attributes.get(ATTR_UNIT_OF_MEASUREMENT))
if (unit := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) != PERCENTAGE:
_LOGGER.error(
"Humidity sensor %s has unsupported unit: %s (allowed: %s)",
state.entity_id,
unit,
PERCENTAGE,
)
return None
def _get_temperature_from_state(self, state: State | None) -> float | None:
"""Get temperature value in Celsius from state."""
if hum > 100 or hum < 0:
_LOGGER.error(
"Humidity sensor %s is out of range: %s (allowed: 0-100)",
state.entity_id,
hum,
)
return None
def validate_temperature(value: float, unit: str | None) -> float | None:
if TYPE_CHECKING:
assert state is not None
return hum
if unit not in UnitOfTemperature:
_LOGGER.warning(
"Temp sensor %s has unsupported unit: %s (allowed: %s, %s)",
state.entity_id,
unit,
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
return None
return TemperatureConverter.convert(value, unit, UnitOfTemperature.CELSIUS)
return self._get_value_from_state(state, validate_temperature)
def _get_humidity_from_state(self, state: State | None) -> float | None:
"""Get humidity value from state."""
def validate_humidity(value: float, unit: str | None) -> float | None:
if TYPE_CHECKING:
assert state is not None
if unit != PERCENTAGE:
_LOGGER.warning(
"Humidity sensor %s has unsupported unit: %s (allowed: %s)",
state.entity_id,
unit,
PERCENTAGE,
)
return None
if not 0 <= value <= 100:
_LOGGER.warning(
"Humidity sensor %s is out of range: %s (allowed: 0-100)",
state.entity_id,
value,
)
return None
return value
return self._get_value_from_state(state, validate_humidity)
async def async_update(self) -> None:
"""Calculate latest state."""
@@ -425,7 +415,7 @@ class MoldIndicator(SensorEntity):
_LOGGER.debug("Dewpoint: %f %s", self._dewpoint, UnitOfTemperature.CELSIUS)
def _calc_moldindicator(self) -> None:
"""Calculate the humidity at the (cold) calibration point."""
"""Calculate the mold indicator value."""
if TYPE_CHECKING:
assert self._outdoor_temp and self._indoor_temp and self._dewpoint
@@ -436,7 +426,6 @@ class MoldIndicator(SensorEntity):
self._calib_factor,
)
self._attr_native_value = None
self._attr_available = False
self._crit_temp = None
return
@@ -464,13 +453,13 @@ class MoldIndicator(SensorEntity):
* 100.0
)
# check bounds and format
# truncate humidity
if crit_humidity > 100:
self._attr_native_value = "100"
self._attr_native_value = 100
elif crit_humidity < 0:
self._attr_native_value = "0"
self._attr_native_value = 0
else:
self._attr_native_value = f"{int(crit_humidity):d}"
self._attr_native_value = int(crit_humidity)
_LOGGER.debug("Mold indicator humidity: %s", self.native_value)

View File

@@ -1,39 +0,0 @@
"""Provides conditions for motion."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, EntityStateConditionBase
class MotionIsDetectedCondition(EntityStateConditionBase):
"""Condition for motion detected (binary sensor ON)."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
_states = {STATE_ON}
class MotionIsNotDetectedCondition(EntityStateConditionBase):
"""Condition for motion not detected (binary sensor OFF)."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
_states = {STATE_OFF}
CONDITIONS: dict[str, type[Condition]] = {
"is_detected": MotionIsDetectedCondition,
"is_not_detected": MotionIsNotDetectedCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for motion."""
return CONDITIONS

View File

@@ -1,24 +0,0 @@
.condition_common_fields: &condition_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_detected:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion
is_not_detected:
fields: *condition_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion

View File

@@ -1,12 +1,4 @@
{
"conditions": {
"is_detected": {
"condition": "mdi:motion-sensor"
},
"is_not_detected": {
"condition": "mdi:motion-sensor-off"
}
},
"triggers": {
"cleared": {
"trigger": "mdi:motion-sensor-off"

View File

@@ -1,39 +1,9 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted motion sensors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_detected": {
"description": "Tests if one or more motion sensors are detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::condition_behavior_description%]",
"name": "[%key:component::motion::common::condition_behavior_name%]"
}
},
"name": "Motion is detected"
},
"is_not_detected": {
"description": "Tests if one or more motion sensors are not detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::condition_behavior_description%]",
"name": "[%key:component::motion::common::condition_behavior_name%]"
}
},
"name": "Motion is not detected"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -7,36 +7,15 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion detected (binary sensor ON)."""
_to_states = {STATE_ON}
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
_MOTION_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": MotionDetectedTrigger,
"cleared": MotionClearedTrigger,
"detected": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_ON),
"cleared": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_OFF),
}

View File

@@ -163,8 +163,6 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
latitude: float | None
longitude: float | None
gps_accuracy: float
# Reset manually set location to allow automatic zone detection
self._attr_location_name = None
if isinstance(
latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float)
) and isinstance(

View File

@@ -24,7 +24,7 @@ class StationPriceData:
prices: dict[tuple[int, str], float]
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData]):
"""Class to manage fetching NSW fuel station data."""
config_entry: None
@@ -40,14 +40,14 @@ class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
)
self.client = client
async def _async_update_data(self) -> StationPriceData | None:
async def _async_update_data(self) -> StationPriceData:
"""Fetch data from API."""
return await self.hass.async_add_executor_job(
_fetch_station_price_data, self.client
)
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None:
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData:
"""Fetch fuel price and station data."""
try:
raw_price_data = client.get_fuel_prices()

View File

@@ -65,10 +65,6 @@ def setup_platform(
coordinator: NSWFuelStationCoordinator = hass.data[DATA_NSW_FUEL_STATION]
if coordinator.data is None:
_LOGGER.error("Initial fuel station price data not available")
return
entities = []
for fuel_type in fuel_types:
if coordinator.data.prices.get((station_id, fuel_type)) is None:
@@ -110,9 +106,6 @@ class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEnt
@property
def native_value(self) -> float | None:
"""Return the state of the sensor."""
if self.coordinator.data is None:
return None
prices = self.coordinator.data.prices
return prices.get((self._station_id, self._fuel_type))
@@ -129,16 +122,13 @@ class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEnt
"""Return the units of measurement."""
return f"{CURRENCY_CENT}/{UnitOfVolume.LITERS}"
def _get_station_name(self):
default_name = f"station {self._station_id}"
if self.coordinator.data is None:
return default_name
def _get_station_name(self) -> str:
if (
station := self.coordinator.data.stations.get(self._station_id)
) is not None:
return station.name
station = self.coordinator.data.stations.get(self._station_id)
if station is None:
return default_name
return station.name
return f"station {self._station_id}"
@property
def unique_id(self) -> str | None:

View File

@@ -7,40 +7,15 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
_OCCUPANCY_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
"detected": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_ON),
"cleared": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF),
}

View File

@@ -54,6 +54,7 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -80,6 +81,7 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
@@ -92,8 +94,10 @@ from .const import (
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS,
RECOMMENDED_WEB_SEARCH_USER_LOCATION,
UNSUPPORTED_CODE_INTERPRETER_MODELS,
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS,
UNSUPPORTED_IMAGE_MODELS,
UNSUPPORTED_MODELS,
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS,
UNSUPPORTED_WEB_SEARCH_MODELS,
)
@@ -443,6 +447,25 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith("gpt-5"):
options.pop(CONF_REASONING_SUMMARY)
service_tiers = self._get_service_tiers(model)
if "flex" in service_tiers or "priority" in service_tiers:
step_schema[
vol.Optional(
CONF_SERVICE_TIER,
default=RECOMMENDED_SERVICE_TIER,
)
] = SelectSelector(
SelectSelectorConfig(
options=service_tiers,
translation_key=CONF_SERVICE_TIER,
mode=SelectSelectorMode.DROPDOWN,
)
)
else:
options.pop(CONF_SERVICE_TIER, None)
if options.get(CONF_SERVICE_TIER) not in service_tiers:
options.pop(CONF_SERVICE_TIER, None)
if self._subentry_type == "conversation" and not model.startswith(
tuple(UNSUPPORTED_WEB_SEARCH_MODELS)
):
@@ -563,6 +586,20 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
return options
return [] # pragma: no cover
def _get_service_tiers(self, model: str) -> list[str]:
"""Get service tier options based on model."""
service_tiers = ["auto"]
if not model.startswith(tuple(UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS)):
service_tiers.append("flex")
service_tiers.append("default")
if not model.startswith(tuple(UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS)):
service_tiers.append("priority")
return service_tiers
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}

View File

@@ -24,6 +24,7 @@ CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
CONF_SERVICE_TIER = "service_tier"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
CONF_TTS_SPEED = "tts_speed"
@@ -42,6 +43,7 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_SERVICE_TIER = "auto"
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
RECOMMENDED_TEMPERATURE = 1.0
RECOMMENDED_TOP_P = 1.0
@@ -119,3 +121,38 @@ RECOMMENDED_TTS_OPTIONS = {
CONF_PROMPT: "",
CONF_CHAT_MODEL: "gpt-4o-mini-tts",
}
UNSUPPORTED_FLEX_SERVICE_TIERS_MODELS: list[str] = [
"gpt-5.3",
"gpt-5.2-chat",
"gpt-5.1-chat",
"gpt-5-chat",
"gpt-5.2-codex",
"gpt-5.1-codex",
"gpt-5-codex",
"gpt-5.2-pro",
"gpt-5-pro",
"gpt-4",
"o1",
"o3-pro",
"o3-deep-research",
"o4-mini-deep-research",
"o3-mini",
"codex-mini",
]
UNSUPPORTED_PRIORITY_SERVICE_TIERS_MODELS: list[str] = [
"gpt-5-nano",
"gpt-5.3-chat",
"gpt-5.2-chat",
"gpt-5.1-chat",
"gpt-5.1-codex-mini",
"gpt-5-chat",
"gpt-5.2-pro",
"gpt-5-pro",
"o1",
"o3-pro",
"o3-deep-research",
"o4-mini-deep-research",
"o3-mini",
"codex-mini",
]

View File

@@ -74,6 +74,7 @@ from .const import (
CONF_MAX_TOKENS,
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_VERBOSITY,
@@ -92,6 +93,7 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STT_MODEL,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -499,6 +501,7 @@ class OpenAIBaseLLMEntity(Entity):
input=messages,
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
user=chat_log.conversation_id,
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
store=False,
stream=True,
)
@@ -655,6 +658,15 @@ class OpenAIBaseLLMEntity(Entity):
)
)
except openai.RateLimitError as err:
if (
model_args["service_tier"] == "flex"
and "resource unavailable" in (err.message or "").lower()
):
LOGGER.info(
"Flex tier is not available at the moment, continuing with default tier"
)
model_args["service_tier"] = "default"
continue
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:

View File

@@ -70,6 +70,7 @@
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]",
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::service_tier%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]"
},
@@ -80,6 +81,7 @@
"reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]",
"reasoning_summary": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_summary%]",
"search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]",
"service_tier": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::service_tier%]",
"user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]"
},
@@ -131,6 +133,7 @@
"reasoning_effort": "Reasoning effort",
"reasoning_summary": "Reasoning summary",
"search_context_size": "Search context size",
"service_tier": "Service tier",
"user_location": "Include home location",
"web_search": "Enable web search"
},
@@ -141,6 +144,7 @@
"reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt",
"reasoning_summary": "Controls the length and detail of reasoning summaries provided by the model",
"search_context_size": "High level guidance for the amount of context window space to use for the search",
"service_tier": "Controls the cost and response time",
"user_location": "Refine search results based on geography",
"web_search": "Allow the model to search the web for the latest information before generating a response"
},
@@ -242,6 +246,14 @@
"medium": "[%key:common::state::medium%]"
}
},
"service_tier": {
"options": {
"auto": "[%key:common::state::auto%]",
"default": "Standard",
"flex": "Flex",
"priority": "Priority"
}
},
"verbosity": {
"options": {
"high": "[%key:common::state::high%]",

View File

@@ -1,10 +1,7 @@
"""Defines base Prana entity."""
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from homeassistant.components.switch import StrEnum
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -12,26 +9,16 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import PranaCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class PranaEntityDescription(EntityDescription):
"""Description for all Prana entities."""
key: StrEnum
class PranaBaseEntity(CoordinatorEntity[PranaCoordinator]):
"""Defines a base Prana entity."""
_attr_has_entity_name = True
_attr_entity_description: PranaEntityDescription
def __init__(
self,
coordinator: PranaCoordinator,
description: PranaEntityDescription,
description: EntityDescription,
) -> None:
"""Initialize the Prana entity."""
super().__init__(coordinator)

View File

@@ -2,6 +2,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
import math
from typing import Any
@@ -21,7 +22,7 @@ from homeassistant.util.percentage import (
from homeassistant.util.scaling import int_states_in_range
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
from .entity import PranaBaseEntity, PranaCoordinator
PARALLEL_UPDATES = 1
@@ -40,14 +41,15 @@ class PranaFanType(StrEnum):
@dataclass(frozen=True, kw_only=True)
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
class PranaFanEntityDescription(FanEntityDescription):
"""Description of a Prana fan entity."""
key: PranaFanType
value_fn: Callable[[PranaCoordinator], FanState]
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
ENTITIES: tuple[PranaEntityDescription, ...] = (
ENTITIES: tuple[PranaFanEntityDescription, ...] = (
PranaFanEntityDescription(
key=PranaFanType.SUPPLY,
translation_key="supply",

View File

@@ -1,20 +1,16 @@
"""Switch platform for Prana integration."""
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from aioesphomeapi import dataclass
from homeassistant.components.switch import (
StrEnum,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PranaConfigEntry, PranaCoordinator
from .entity import PranaBaseEntity, PranaEntityDescription
from .entity import PranaBaseEntity
PARALLEL_UPDATES = 1
@@ -32,13 +28,14 @@ class PranaSwitchType(StrEnum):
@dataclass(frozen=True, kw_only=True)
class PranaSwitchEntityDescription(SwitchEntityDescription, PranaEntityDescription):
class PranaSwitchEntityDescription(SwitchEntityDescription):
"""Description of a Prana switch entity."""
key: PranaSwitchType
value_fn: Callable[[PranaCoordinator], bool]
ENTITIES: tuple[PranaEntityDescription, ...] = (
ENTITIES: tuple[PranaSwitchEntityDescription, ...] = (
PranaSwitchEntityDescription(
key=PranaSwitchType.BOUND,
translation_key="bound",

View File

@@ -0,0 +1,16 @@
"""Common methods for Proxmox VE integration."""
from typing import Any
from homeassistant.const import CONF_USERNAME
from .const import CONF_REALM
def sanitize_userid(data: dict[str, Any]) -> str:
"""Sanitize the user ID."""
return (
data[CONF_USERNAME]
if "@" in data[CONF_USERNAME]
else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
)

View File

@@ -23,6 +23,7 @@ from homeassistant.const import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .common import sanitize_userid
from .const import (
CONF_CONTAINERS,
CONF_NODE,
@@ -48,22 +49,13 @@ CONFIG_SCHEMA = vol.Schema(
)
def _sanitize_userid(data: dict[str, Any]) -> str:
"""Sanitize the user ID."""
return (
data[CONF_USERNAME]
if "@" in data[CONF_USERNAME]
else f"{data[CONF_USERNAME]}@{data[CONF_REALM]}"
)
def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Validate the user input and fetch data (sync, for executor)."""
try:
client = ProxmoxAPI(
data[CONF_HOST],
port=data[CONF_PORT],
user=_sanitize_userid(data),
user=sanitize_userid(data),
password=data[CONF_PASSWORD],
verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)

View File

@@ -14,13 +14,7 @@ import requests
from requests.exceptions import ConnectTimeout, SSLError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
@@ -29,7 +23,8 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_NODE, CONF_REALM, DEFAULT_VERIFY_SSL, DOMAIN
from .common import sanitize_userid
from .const import CONF_NODE, DEFAULT_VERIFY_SSL, DOMAIN
type ProxmoxConfigEntry = ConfigEntry[ProxmoxCoordinator]
@@ -177,16 +172,10 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
def _init_proxmox(self) -> None:
"""Initialize ProxmoxAPI instance."""
user_id = (
self.config_entry.data[CONF_USERNAME]
if "@" in self.config_entry.data[CONF_USERNAME]
else f"{self.config_entry.data[CONF_USERNAME]}@{self.config_entry.data[CONF_REALM]}"
)
self.proxmox = ProxmoxAPI(
host=self.config_entry.data[CONF_HOST],
port=self.config_entry.data[CONF_PORT],
user=user_id,
user=sanitize_userid(dict(self.config_entry.data)),
password=self.config_entry.data[CONF_PASSWORD],
verify_ssl=self.config_entry.data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
)

View File

@@ -12,7 +12,7 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.2.16"],
"requirements": ["pysmlight==0.3.0"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."

View File

@@ -33,6 +33,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_BROWSE_LIMIT,
CONF_HTTPS,
CONF_SERVER_LIST,
CONF_VOLUME_STEP,
DEFAULT_BROWSE_LIMIT,
DEFAULT_PORT,
@@ -45,45 +46,23 @@ _LOGGER = logging.getLogger(__name__)
TIMEOUT = 5
def _base_schema(
discovery_info: dict[str, Any] | None = None,
) -> vol.Schema:
"""Generate base schema."""
base_schema: dict[Any, Any] = {}
if discovery_info and CONF_HOST in discovery_info:
base_schema.update(
{
vol.Required(
CONF_HOST,
description={"suggested_value": discovery_info[CONF_HOST]},
): str,
}
)
else:
base_schema.update({vol.Required(CONF_HOST): str})
FULL_EDIT_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_HTTPS, default=False): bool,
}
)
if discovery_info and CONF_PORT in discovery_info:
base_schema.update(
{
vol.Required(
CONF_PORT,
default=DEFAULT_PORT,
description={"suggested_value": discovery_info[CONF_PORT]},
): int,
}
)
else:
base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int})
base_schema.update(
{
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_HTTPS, default=False): bool,
}
)
return vol.Schema(base_schema)
SHORT_EDIT_SCHEMA = vol.Schema(
{
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Optional(CONF_HTTPS, default=False): bool,
}
)
class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -93,8 +72,9 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize an instance of the squeezebox config flow."""
self.data_schema = _base_schema()
self.discovery_info: dict[str, Any] | None = None
self.discovery_task: asyncio.Task | None = None
self.discovered_servers: list[dict[str, Any]] = []
self.chosen_server: dict[str, Any] = {}
@staticmethod
@callback
@@ -102,34 +82,43 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return OptionsFlowHandler()
async def _discover(self, uuid: str | None = None) -> None:
async def _discover(self) -> None:
"""Discover an unconfigured LMS server."""
self.discovery_info = None
discovery_event = asyncio.Event()
# Reset discovery state to avoid stale or duplicate servers across runs
self.discovered_servers = []
self.chosen_server = {}
_discovery_task: asyncio.Task | None = None
def _discovery_callback(server: Server) -> None:
_discovery_info: dict[str, Any] | None = {}
if server.uuid:
# ignore already configured uuids
for entry in self._async_current_entries():
if entry.unique_id == server.uuid:
return
self.discovery_info = {
_discovery_info = {
CONF_HOST: server.host,
CONF_PORT: int(server.port),
"uuid": server.uuid,
"name": server.name,
}
_LOGGER.debug("Discovered server: %s", self.discovery_info)
discovery_event.set()
discovery_task = self.hass.async_create_task(
_LOGGER.debug(
"Discovered server: %s, creating discovery_info %s",
server,
_discovery_info,
)
if _discovery_info not in self.discovered_servers:
self.discovered_servers.append(_discovery_info)
_discovery_task = self.hass.async_create_task(
async_discover(_discovery_callback)
)
await discovery_event.wait()
discovery_task.cancel() # stop searching as soon as we find server
await asyncio.sleep(TIMEOUT)
# update with suggested values from discovery
self.data_schema = _base_schema(self.discovery_info)
_LOGGER.debug("Discovered Servers %s", self.discovered_servers)
_discovery_task.cancel()
async def _validate_input(self, data: dict[str, Any]) -> str | None:
"""Validate the user input allows us to connect.
@@ -142,7 +131,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
data[CONF_PORT],
data.get(CONF_USERNAME),
data.get(CONF_PASSWORD),
https=data[CONF_HTTPS],
https=data.get(CONF_HTTPS, False),
)
try:
@@ -164,35 +153,78 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
return None
async def async_step_choose_server(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose manual or discover flow."""
_chosen_host: str
if user_input:
_chosen_host = user_input[CONF_SERVER_LIST]
for _server in self.discovered_servers:
if _chosen_host == _server[CONF_HOST]:
self.chosen_server[CONF_HOST] = _chosen_host
self.chosen_server[CONF_PORT] = _server[CONF_PORT]
self.chosen_server[CONF_HTTPS] = False
return await self.async_step_edit_discovered()
_options = {
_server[CONF_HOST]: f"{_server['name']} ({_server[CONF_HOST]})"
for _server in self.discovered_servers
}
return self.async_show_form(
step_id="choose_server",
data_schema=vol.Schema({vol.Required(CONF_SERVER_LIST): vol.In(_options)}),
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input and CONF_HOST in user_input:
# update with host provided by user
self.data_schema = _base_schema(user_input)
return await self.async_step_edit()
# no host specified, see if we can discover an unconfigured LMS server
try:
async with asyncio.timeout(TIMEOUT):
await self._discover()
return await self.async_step_edit()
except TimeoutError:
errors["base"] = "no_server_found"
return self.async_show_menu(
step_id="user", menu_options=["start_discovery", "edit"]
)
# display the form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_HOST): str}),
errors=errors,
async def async_step_discovery_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a failed discovery."""
return self.async_show_menu(step_id="discovery_failed", menu_options=["edit"])
async def async_step_start_discovery(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
if not self.discovery_task:
self.discovery_task = self.hass.async_create_task(self._discover())
if self.discovery_task.done():
self.discovery_task.cancel()
self.discovery_task = None
# Sleep to allow task cancellation to complete
await asyncio.sleep(0.1)
return self.async_show_progress_done(
next_step_id="choose_server"
if self.discovered_servers
else "discovery_failed"
)
return self.async_show_progress(
step_id="start_discovery",
progress_action="start_discovery",
progress_task=self.discovery_task,
)
async def async_step_edit(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Edit a discovered or manually inputted server."""
errors = {}
if user_input:
error = await self._validate_input(user_input)
@@ -203,39 +235,95 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = error
return self.async_show_form(
step_id="edit", data_schema=self.data_schema, errors=errors
step_id="edit",
data_schema=FULL_EDIT_SCHEMA,
errors=errors,
)
async def async_step_edit_discovered(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Edit a discovered or manually inputted server."""
if not (await self._validate_input(self.chosen_server)):
# Attempt to connect with default data successful
return self.async_create_entry(
title=self.chosen_server[CONF_HOST], data=self.chosen_server
)
errors = {}
if user_input:
user_input[CONF_HOST] = self.chosen_server[CONF_HOST]
user_input[CONF_PORT] = self.chosen_server[CONF_PORT]
error = await self._validate_input(user_input)
if not error:
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
errors["base"] = error
return self.async_show_form(
step_id="edit_discovered",
description_placeholders={
"host": self.chosen_server[CONF_HOST],
"port": self.chosen_server[CONF_PORT],
},
data_schema=SHORT_EDIT_SCHEMA,
errors=errors,
)
async def async_step_edit_integration_discovered(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Edit a discovered or manually inputted server."""
errors = {}
if user_input:
user_input[CONF_HOST] = self.chosen_server[CONF_HOST]
user_input[CONF_PORT] = self.chosen_server[CONF_PORT]
error = await self._validate_input(user_input)
if not error:
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
errors["base"] = error
return self.async_show_form(
step_id="edit_integration_discovered",
description_placeholders={
"desc": f"LMS Host: {self.chosen_server[CONF_HOST]}, Port: {self.chosen_server[CONF_PORT]}"
},
data_schema=SHORT_EDIT_SCHEMA,
errors=errors,
)
async def async_step_integration_discovery(
self, discovery_info: dict[str, Any]
self, _discovery_info: dict[str, Any]
) -> ConfigFlowResult:
"""Handle discovery of a server."""
_LOGGER.debug("Reached server discovery flow with info: %s", discovery_info)
if "uuid" in discovery_info:
await self.async_set_unique_id(discovery_info.pop("uuid"))
_LOGGER.debug("Reached server discovery flow with info: %s", _discovery_info)
if "uuid" in _discovery_info:
await self.async_set_unique_id(_discovery_info.pop("uuid"))
self._abort_if_unique_id_configured()
else:
# attempt to connect to server and determine uuid. will fail if
# password required
error = await self._validate_input(discovery_info)
error = await self._validate_input(_discovery_info)
if error:
await self._async_handle_discovery_without_unique_id()
# update schema with suggested values from discovery
self.data_schema = _base_schema(discovery_info)
self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}})
return await self.async_step_edit()
self.context.update(
{"title_placeholders": {"host": _discovery_info[CONF_HOST]}}
)
self.chosen_server = _discovery_info
return await self.async_step_edit_integration_discovered()
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
self, _discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle dhcp discovery of a Squeezebox player."""
_LOGGER.debug(
"Reached dhcp discovery of a player with info: %s", discovery_info
"Reached dhcp discovery of a player with info: %s", _discovery_info
)
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
await self.async_set_unique_id(format_mac(_discovery_info.macaddress))
self._abort_if_unique_id_configured()
_LOGGER.debug("Configuring dhcp player with unique id: %s", self.unique_id)

View File

@@ -56,3 +56,4 @@ ATTR_VOLUME = "volume"
ATTR_URL = "url"
UPDATE_PLUGINS_RELEASE_SUMMARY = "update_plugins_release_summary"
UPDATE_RELEASE_SUMMARY = "update_release_summary"
CONF_SERVER_LIST = "server_list"

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_server_found": "No LMS found."
},
"error": {
@@ -12,7 +13,27 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{host}",
"progress": {
"start_discovery": "Attempting to discover new LMS servers\n\nThis will take about 5 seconds",
"title": "LMS discovery"
},
"step": {
"choose_server": {
"data": {
"server_list": "Server list"
},
"data_description": {
"server_list": "Choose the server to configure."
},
"title": "Discovered servers"
},
"discovery_failed": {
"description": "No LMS were discovered on the network.",
"menu_options": {
"edit": "Enter configuration manually"
},
"title": "Discovery failed"
},
"edit": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
@@ -22,21 +43,47 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "[%key:component::squeezebox::config::step::user::data_description::host%]",
"host": "The IP address or hostname of the LMS.",
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
"password": "The password from LMS Advanced Security (if defined).",
"port": "The web interface port on the LMS. The default is 9000.",
"username": "The username from LMS Advanced Security (if defined)."
},
"title": "Edit connection information"
}
},
"user": {
"edit_discovered": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
"https": "Connect over HTTPS (requires reverse proxy)",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your Lyrion Music Server."
}
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
"password": "The password from LMS Advanced Security (if defined).",
"username": "The username from LMS Advanced Security (if defined)."
},
"description": "LMS Host: {host}, Port {port}",
"title": "Edit additional connection information"
},
"edit_integration_discovered": {
"data": {
"https": "Connect over HTTPS (requires reverse proxy)",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"https": "Connect to the LMS over HTTPS (requires reverse proxy).",
"password": "The password from LMS Advanced Security (if defined).",
"username": "The username from LMS Advanced Security (if defined)."
},
"description": "{desc}",
"title": "Edit additional connection information"
},
"user": {
"menu_options": {
"edit": "Enter configuration manually",
"start_discovery": "Discover new LMS"
},
"title": "LMS configuration"
}
}
},
@@ -260,11 +307,11 @@
"description": "Calls a custom Squeezebox JSONRPC API.",
"fields": {
"command": {
"description": "Command to pass to Lyrion Music Server (p0 in the CLI documentation).",
"description": "Command to pass to LMS (p0 in the CLI documentation).",
"name": "Command"
},
"parameters": {
"description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).",
"description": "Array of additional parameters to pass to LMS (p1, ..., pN in the CLI documentation).",
"name": "Parameters"
}
},

View File

@@ -245,15 +245,17 @@ async def make_device_data(
devices_data.binary_sensors.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan",
"Circulator Fan",
]:
if isinstance(device, Device) and device.device_type == "Battery Circulator Fan":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
if isinstance(device, Device) and device.device_type == "Circulator Fan":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Curtain",
"Curtain3",

View File

@@ -1,4 +1,4 @@
"""Support for the Switchbot Battery Circulator fan."""
"""Support for the Switchbot (Battery) Circulator fan."""
import asyncio
import logging
@@ -43,7 +43,7 @@ async def async_setup_entry(
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
"""Representation of a SwitchBot Battery Circulator Fan."""
"""Representation of a SwitchBot (Battery) Circulator Fan."""
_attr_name = None
@@ -110,10 +110,6 @@ class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=str(BatteryCirculatorFanMode.DIRECT.value),
)
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(percentage),

View File

@@ -141,6 +141,7 @@ class BaseTelegramBot:
"""Initialize the bot base class."""
self.hass = hass
self.config = config
self.most_recent_chat_id: int | None = None
self._bot = bot
@abstractmethod
@@ -150,8 +151,6 @@ class BaseTelegramBot:
async def handle_update(self, update: Update, context: CallbackContext) -> bool:
"""Handle updates from bot application set up by the respective platform."""
_LOGGER.debug("Handling update %s", update)
if not self.authorize_update(update):
return False
# establish event type: text, command or callback_query
if update.callback_query:
@@ -168,6 +167,11 @@ class BaseTelegramBot:
_LOGGER.warning("Unhandled update: %s", update)
return True
self.most_recent_chat_id = event_data[ATTR_CHAT_ID]
if not self.authorize_update(update):
return False
event_data["bot"] = _get_bot_info(self._bot, self.config)
event_context = Context()

View File

@@ -619,14 +619,66 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow):
description_placeholders["bot_username"] = f"@{service.bot.username}"
description_placeholders["bot_url"] = f"https://t.me/{service.bot.username}"
# suggest chat id based on the most recent chat
suggested_values = {}
description_placeholders["most_recent_chat"] = "Not available"
try:
most_recent_chat = await _get_most_recent_chat(service)
except TelegramError as err:
_LOGGER.warning("Error occurred while fetching recent chat: %s", err)
most_recent_chat = None
if most_recent_chat is not None:
suggested_values[CONF_CHAT_ID] = most_recent_chat[0]
description_placeholders["most_recent_chat"] = (
f"{most_recent_chat[1]} ({most_recent_chat[0]})"
if most_recent_chat[1]
else str(most_recent_chat[0])
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}),
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}),
suggested_values,
),
description_placeholders=description_placeholders,
errors=errors,
)
async def _get_most_recent_chat(
service: TelegramNotificationService,
) -> tuple[int, str | None] | None:
"""Get the most recent chat ID and name.
For broadcast bot, this is retrieved using get_updates() to find the most recent message received.
For polling or webhook bot, this is retrieved from the runtime data which is updated whenever a message is received.
"""
if service.app is not None:
# this is either polling or webhook bot
if service.app.most_recent_chat_id is None:
return None
chat = await service.bot.get_chat(service.app.most_recent_chat_id)
return (service.app.most_recent_chat_id, chat.effective_name)
# broadcast bot
updates = await service.bot.get_updates(offset=0)
if updates:
last_update = updates[-1]
if last_update.effective_chat:
chat_name = last_update.effective_chat.effective_name
return (
last_update.effective_chat.id,
chat_name,
)
return None
async def _async_get_chat_name(bot: Bot, chat_id: int) -> str:
try:
chat_info: ChatFullInfo = await bot.get_chat(chat_id)

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["telegram"],
"quality_scale": "gold",
"requirements": ["python-telegram-bot[socks]==22.1"]
"requirements": ["python-telegram-bot[socks]==22.6"]
}

View File

@@ -105,7 +105,7 @@
"data_description": {
"chat_id": "ID representing the user or group chat to which messages can be sent."
},
"description": "Before you proceed, send any message to your bot: [{bot_username}]({bot_url}). This is required because Telegram prevents bots from initiating chats with users.\n\nThen follow these steps to get your chat ID:\n\n1. Open Telegram and start a chat with [{id_bot_username}]({id_bot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `ID` field of the bot's response.",
"description": "Before you proceed, send any message to your bot: [{bot_username}]({bot_url}). This is required because Telegram prevents bots from initiating chats with users.\n\nThen follow these steps to get your chat ID:\n\n1. Open Telegram and start a chat with [{id_bot_username}]({id_bot_url}).\n1. Send any message to the bot.\n1. Your chat ID is in the `ID` field of the bot's response.\n\nMost recent chat: {most_recent_chat}",
"title": "Add chat"
}
}

View File

@@ -18,6 +18,12 @@ class TextChangedTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)

View File

@@ -31,13 +31,13 @@ rules:
docs-configuration-parameters:
status: exempt
comment: There are no configuration parameters
docs-installation-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices: done
@@ -48,13 +48,13 @@ rules:
discovery:
status: exempt
comment: Can't be discovered
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
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

View File

@@ -2,13 +2,15 @@
from __future__ import annotations
from base64 import b64decode
from typing import Any
from tuya_device_handlers.device_wrapper.alarm_control_panel import (
AlarmActionWrapper,
AlarmChangedByWrapper,
AlarmStateWrapper,
)
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeEnumWrapper,
DPCodeRawWrapper,
from tuya_device_handlers.helpers.homeassistant import (
TuyaAlarmControlPanelAction,
TuyaAlarmControlPanelState,
)
from tuya_device_handlers.type_information import EnumTypeInformation
from tuya_sharing import CustomerDevice, Manager
@@ -36,84 +38,18 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
)
}
class _AlarmChangedByWrapper(DPCodeRawWrapper[str]):
"""Wrapper for changed_by.
Decode base64 to utf-16be string, but only if alarm has been triggered.
"""
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device status."""
if (
device.status.get(DPCode.MASTER_STATE) != "alarm"
or (status := self._read_dpcode_value(device)) is None
):
return None
return status.decode("utf-16be")
class _AlarmStateWrapper(DPCodeEnumWrapper[AlarmControlPanelState]):
"""Wrapper for the alarm state of a device.
Handles alarm mode enum values and determines the alarm state,
including logic for detecting when the alarm is triggered and
distinguishing triggered state from battery warnings.
"""
_STATE_MAPPINGS = {
# Tuya device mode => Home Assistant panel state
"disarmed": AlarmControlPanelState.DISARMED,
"arm": AlarmControlPanelState.ARMED_AWAY,
"home": AlarmControlPanelState.ARMED_HOME,
"sos": AlarmControlPanelState.TRIGGERED,
}
def read_device_status(
self, device: CustomerDevice
) -> AlarmControlPanelState | None:
"""Read the device status."""
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if device.status.get(DPCode.MASTER_STATE) == "alarm":
# Only report as triggered if NOT a battery warning
if not (
(encoded_msg := device.status.get(DPCode.ALARM_MSG))
and (decoded_message := b64decode(encoded_msg).decode("utf-16be"))
and "Sensor Low Battery" in decoded_message
):
return AlarmControlPanelState.TRIGGERED
if (status := self._read_dpcode_value(device)) is None:
return None
return self._STATE_MAPPINGS.get(status)
class _AlarmActionWrapper(DPCodeEnumWrapper):
"""Wrapper for setting the alarm mode of a device."""
_ACTION_MAPPINGS = {
# Home Assistant action => Tuya device mode
"arm_home": "home",
"arm_away": "arm",
"disarm": "disarmed",
"trigger": "sos",
}
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init _AlarmActionWrapper."""
super().__init__(dpcode, type_information)
self.options = [
ha_action
for ha_action, tuya_action in self._ACTION_MAPPINGS.items()
if tuya_action in type_information.range
]
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert value to raw value."""
if value in self.options:
return self._ACTION_MAPPINGS[value]
raise ValueError(f"Unsupported value {value} for {self.dpcode}")
_TUYA_TO_HA_STATE_MAPPINGS = {
TuyaAlarmControlPanelState.DISARMED: AlarmControlPanelState.DISARMED,
TuyaAlarmControlPanelState.ARMED_HOME: AlarmControlPanelState.ARMED_HOME,
TuyaAlarmControlPanelState.ARMED_AWAY: AlarmControlPanelState.ARMED_AWAY,
TuyaAlarmControlPanelState.ARMED_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
TuyaAlarmControlPanelState.ARMED_VACATION: AlarmControlPanelState.ARMED_VACATION,
TuyaAlarmControlPanelState.ARMED_CUSTOM_BYPASS: AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
TuyaAlarmControlPanelState.PENDING: AlarmControlPanelState.PENDING,
TuyaAlarmControlPanelState.ARMING: AlarmControlPanelState.ARMING,
TuyaAlarmControlPanelState.DISARMING: AlarmControlPanelState.DISARMING,
TuyaAlarmControlPanelState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
}
async def async_setup_entry(
@@ -136,13 +72,13 @@ async def async_setup_entry(
device,
manager,
description,
action_wrapper=_AlarmActionWrapper(
action_wrapper=AlarmActionWrapper(
master_mode.dpcode, master_mode
),
changed_by_wrapper=_AlarmChangedByWrapper.find_dpcode(
changed_by_wrapper=AlarmChangedByWrapper.find_dpcode(
device, DPCode.ALARM_MSG
),
state_wrapper=_AlarmStateWrapper(
state_wrapper=AlarmStateWrapper(
master_mode.dpcode, master_mode
),
)
@@ -174,9 +110,9 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device_manager: Manager,
description: AlarmControlPanelEntityDescription,
*,
action_wrapper: DeviceWrapper[str],
action_wrapper: DeviceWrapper[TuyaAlarmControlPanelAction],
changed_by_wrapper: DeviceWrapper[str] | None,
state_wrapper: DeviceWrapper[AlarmControlPanelState],
state_wrapper: DeviceWrapper[TuyaAlarmControlPanelState],
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
@@ -187,17 +123,18 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
self._state_wrapper = state_wrapper
# Determine supported modes
if "arm_home" in action_wrapper.options:
if TuyaAlarmControlPanelAction.ARM_HOME in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if "arm_away" in action_wrapper.options:
if TuyaAlarmControlPanelAction.ARM_AWAY in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if "trigger" in action_wrapper.options:
if TuyaAlarmControlPanelAction.TRIGGER in action_wrapper.options:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
return self._read_wrapper(self._state_wrapper)
tuya_value = self._read_wrapper(self._state_wrapper)
return _TUYA_TO_HA_STATE_MAPPINGS.get(tuya_value) if tuya_value else None
@property
def changed_by(self) -> str | None:
@@ -206,16 +143,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_wrapper_updates(self._action_wrapper, "disarm")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaAlarmControlPanelAction.DISARM
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_wrapper_updates(self._action_wrapper, "arm_home")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaAlarmControlPanelAction.ARM_HOME
)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_wrapper_updates(self._action_wrapper, "arm_away")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaAlarmControlPanelAction.ARM_AWAY
)
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_wrapper_updates(self._action_wrapper, "trigger")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaAlarmControlPanelAction.TRIGGER
)

View File

@@ -2,17 +2,25 @@
from __future__ import annotations
import collections
from dataclasses import dataclass
from typing import Any, Self
from typing import Any, cast
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.climate import (
DefaultHVACModeWrapper,
DefaultPresetModeWrapper,
SwingModeCompositeWrapper,
)
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from tuya_device_handlers.type_information import EnumTypeInformation
from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper
from tuya_device_handlers.helpers.homeassistant import (
TuyaClimateHVACMode,
TuyaClimateSwingMode,
)
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.climate import (
@@ -41,173 +49,25 @@ from .const import (
)
from .entity import TuyaEntity
TUYA_HVAC_TO_HA = {
"auto": HVACMode.HEAT_COOL,
"cold": HVACMode.COOL,
"freeze": HVACMode.COOL,
"heat": HVACMode.HEAT,
"hot": HVACMode.HEAT,
"manual": HVACMode.HEAT_COOL,
"off": HVACMode.OFF,
"wet": HVACMode.DRY,
"wind": HVACMode.FAN_ONLY,
_TUYA_TO_HA_HVACMODE_MAPPINGS = {
TuyaClimateHVACMode.OFF: HVACMode.OFF,
TuyaClimateHVACMode.HEAT: HVACMode.HEAT,
TuyaClimateHVACMode.COOL: HVACMode.COOL,
TuyaClimateHVACMode.FAN_ONLY: HVACMode.FAN_ONLY,
TuyaClimateHVACMode.DRY: HVACMode.DRY,
TuyaClimateHVACMode.HEAT_COOL: HVACMode.HEAT_COOL,
TuyaClimateHVACMode.AUTO: HVACMode.AUTO,
}
_HA_TO_TUYA_HVACMODE_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_HVACMODE_MAPPINGS.items()}
class _RoundedIntegerWrapper(DPCodeIntegerWrapper[int]):
"""An integer that always rounds its value."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Read and round the device status."""
if (value := self._read_dpcode_value(device)) is None:
return None
return round(value)
@dataclass(kw_only=True)
class _SwingModeWrapper(DeviceWrapper[str]):
"""Wrapper for managing climate swing mode operations across multiple DPCodes."""
on_off: DPCodeBooleanWrapper | None = None
horizontal: DPCodeBooleanWrapper | None = None
vertical: DPCodeBooleanWrapper | None = None
options: list[str]
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
"""Find and return a _SwingModeWrapper for the given DP codes."""
on_off = DPCodeBooleanWrapper.find_dpcode(
device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True
)
horizontal = DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_HORIZONTAL, prefer_function=True
)
vertical = DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_VERTICAL, prefer_function=True
)
if on_off or horizontal or vertical:
options = [SWING_OFF]
if on_off:
options.append(SWING_ON)
if horizontal:
options.append(SWING_HORIZONTAL)
if vertical:
options.append(SWING_VERTICAL)
return cls(
on_off=on_off,
horizontal=horizontal,
vertical=vertical,
options=options,
)
return None
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device swing mode."""
if self.on_off and self.on_off.read_device_status(device):
return SWING_ON
horizontal = (
self.horizontal.read_device_status(device) if self.horizontal else None
)
vertical = self.vertical.read_device_status(device) if self.vertical else None
if horizontal and vertical:
return SWING_BOTH
if horizontal:
return SWING_HORIZONTAL
if vertical:
return SWING_VERTICAL
return SWING_OFF
def get_update_commands(
self, device: CustomerDevice, value: str
) -> list[dict[str, Any]]:
"""Set new target swing operation."""
commands = []
if self.on_off:
commands.extend(self.on_off.get_update_commands(device, value == SWING_ON))
if self.vertical:
commands.extend(
self.vertical.get_update_commands(
device, value in (SWING_BOTH, SWING_VERTICAL)
)
)
if self.horizontal:
commands.extend(
self.horizontal.get_update_commands(
device, value in (SWING_BOTH, SWING_HORIZONTAL)
)
)
return commands
def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
"""Filter TUYA_HVAC_TO_HA modes that are not in the range.
If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid
ambiguity when converting back from HA to Tuya modes.
"""
modes_in_range = {
tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range
}
modes_occurrences = collections.Counter(modes_in_range.values())
for key, value in modes_in_range.items():
if value is not None and modes_occurrences[value] > 1:
modes_in_range[key] = None
return modes_in_range
class _HvacModeWrapper(DPCodeEnumWrapper[HVACMode]):
"""Wrapper for managing climate HVACMode."""
# Modes that do not map to HVAC modes are ignored (they are handled by PresetWrapper)
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init _HvacModeWrapper."""
super().__init__(dpcode, type_information)
self._mappings = _filter_hvac_mode_mappings(type_information.range)
self.options = [
ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
]
def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
"""Read the device status."""
if (raw := self._read_dpcode_value(device)) not in TUYA_HVAC_TO_HA:
return None
return TUYA_HVAC_TO_HA[raw]
def _convert_value_to_raw_value(
self,
device: CustomerDevice,
value: HVACMode,
) -> Any:
"""Convert value to raw value."""
return next(
tuya_mode
for tuya_mode, ha_mode in self._mappings.items()
if ha_mode == value
)
class _PresetWrapper(DPCodeEnumWrapper):
"""Wrapper for managing climate preset modes."""
# Modes that map to HVAC modes are ignored (they are handled by HVACModeWrapper)
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
"""Init _PresetWrapper."""
super().__init__(dpcode, type_information)
mappings = _filter_hvac_mode_mappings(type_information.range)
self.options = [
tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None
]
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device status."""
if (raw := self._read_dpcode_value(device)) not in self.options:
return None
return raw
_TUYA_TO_HA_SWING_MAPPINGS = {
TuyaClimateSwingMode.BOTH: SWING_BOTH,
TuyaClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL,
TuyaClimateSwingMode.OFF: SWING_OFF,
TuyaClimateSwingMode.ON: SWING_ON,
TuyaClimateSwingMode.VERTICAL: SWING_VERTICAL,
}
_HA_TO_TUYA_SWING_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_SWING_MAPPINGS.items()}
@dataclass(frozen=True, kw_only=True)
@@ -358,7 +218,7 @@ async def async_setup_entry(
device,
manager,
CLIMATE_DESCRIPTIONS[device.category],
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_CURRENT
),
current_temperature_wrapper=temperature_wrappers[0],
@@ -367,18 +227,18 @@ async def async_setup_entry(
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
prefer_function=True,
),
hvac_mode_wrapper=_HvacModeWrapper.find_dpcode(
hvac_mode_wrapper=DefaultHVACModeWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
preset_wrapper=_PresetWrapper.find_dpcode(
preset_wrapper=DefaultPresetModeWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
set_temperature_wrapper=temperature_wrappers[1],
swing_wrapper=_SwingModeWrapper.find_dpcode(device),
swing_wrapper=SwingModeCompositeWrapper.find_dpcode(device),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH, prefer_function=True
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_SET, prefer_function=True
),
temperature_unit=temperature_wrappers[2],
@@ -408,10 +268,10 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
current_humidity_wrapper: DeviceWrapper[int] | None,
current_temperature_wrapper: DeviceWrapper[float] | None,
fan_mode_wrapper: DeviceWrapper[str] | None,
hvac_mode_wrapper: DeviceWrapper[HVACMode] | None,
hvac_mode_wrapper: DeviceWrapper[TuyaClimateHVACMode] | None,
preset_wrapper: DeviceWrapper[str] | None,
set_temperature_wrapper: DeviceWrapper[float] | None,
swing_wrapper: DeviceWrapper[str] | None,
swing_wrapper: DeviceWrapper[TuyaClimateSwingMode] | None,
switch_wrapper: DeviceWrapper[bool] | None,
target_humidity_wrapper: DeviceWrapper[int] | None,
temperature_unit: UnitOfTemperature,
@@ -444,10 +304,12 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._attr_hvac_modes = []
if hvac_mode_wrapper:
self._attr_hvac_modes = [HVACMode.OFF]
for mode in hvac_mode_wrapper.options:
if mode != HVACMode.OFF:
for tuya_mode in cast(list[TuyaClimateHVACMode], hvac_mode_wrapper.options):
if (
ha_mode := _TUYA_TO_HA_HVACMODE_MAPPINGS.get(tuya_mode)
) and ha_mode != HVACMode.OFF:
# OFF is always added first
self._attr_hvac_modes.append(HVACMode(mode))
self._attr_hvac_modes.append(ha_mode)
elif switch_wrapper:
self._attr_hvac_modes = [
@@ -475,7 +337,13 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
# Determine swing modes
if swing_wrapper:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._attr_swing_modes = swing_wrapper.options
self._attr_swing_modes = [
ha_swing_mode
for tuya_swing_mode in cast(
list[TuyaClimateSwingMode], swing_wrapper.options
)
if (ha_swing_mode := _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_swing_mode))
]
if switch_wrapper:
self._attr_supported_features |= (
@@ -491,9 +359,13 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self.device, hvac_mode != HVACMode.OFF
)
)
if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options:
if (
self._hvac_mode_wrapper
and (tuya_mode := _HA_TO_TUYA_HVACMODE_MAPPINGS.get(hvac_mode))
and tuya_mode in self._hvac_mode_wrapper.options
):
commands.extend(
self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode)
self._hvac_mode_wrapper.get_update_commands(self.device, tuya_mode)
)
await self._async_send_commands(commands)
@@ -511,7 +383,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
await self._async_send_wrapper_updates(self._swing_wrapper, swing_mode)
if tuya_mode := _HA_TO_TUYA_SWING_MAPPINGS.get(swing_mode):
await self._async_send_wrapper_updates(self._swing_wrapper, tuya_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -554,7 +427,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
return None
# If we do have a mode wrapper, check if the mode maps to an HVAC mode.
return self._read_wrapper(self._hvac_mode_wrapper)
tuya_mode = self._read_wrapper(self._hvac_mode_wrapper)
return _TUYA_TO_HA_HVACMODE_MAPPINGS.get(tuya_mode) if tuya_mode else None
@property
def preset_mode(self) -> str | None:
@@ -569,7 +443,8 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property
def swing_mode(self) -> str | None:
"""Return swing mode."""
return self._read_wrapper(self._swing_wrapper)
tuya_value = self._read_wrapper(self._swing_wrapper)
return _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_value) if tuya_value else None
async def async_turn_on(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""

View File

@@ -6,16 +6,19 @@ from dataclasses import dataclass
from typing import Any
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
from tuya_device_handlers.device_wrapper.cover import (
ControlBackModePercentageMappingWrapper,
CoverClosedEnumWrapper,
CoverInstructionBooleanWrapper,
CoverInstructionEnumWrapper,
CoverInstructionSpecialEnumWrapper,
)
from tuya_device_handlers.type_information import (
EnumTypeInformation,
IntegerTypeInformation,
from tuya_device_handlers.device_wrapper.extended import (
DPCodeInvertedBooleanWrapper,
DPCodeInvertedPercentageWrapper,
DPCodePercentageWrapper,
)
from tuya_device_handlers.utils import RemapHelper
from tuya_device_handlers.helpers.homeassistant import TuyaCoverAction
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.cover import (
@@ -35,123 +38,17 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper[int]):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self._remap_helper = RemapHelper.from_type_information(type_information, 0, 100)
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return False
def read_device_status(self, device: CustomerDevice) -> int | None:
if (value := device.status.get(self.dpcode)) is None:
return None
return round(
self._remap_helper.remap_value_to(
value, reverse=self._position_reversed(device)
)
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
return round(
self._remap_helper.remap_value_from(
value, reverse=self._position_reversed(device)
)
)
class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return True
class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values with control_back_mode support."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
class _InstructionBooleanWrapper(DPCodeBooleanWrapper):
"""Wrapper for boolean-based open/close instructions."""
options = ["open", "close"]
_ACTION_MAPPINGS = {"open": True, "close": False}
def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> bool:
return self._ACTION_MAPPINGS[value]
class _InstructionEnumWrapper(DPCodeEnumWrapper):
"""Wrapper for enum-based open/close/stop instructions."""
_ACTION_MAPPINGS = {"open": "open", "close": "close", "stop": "stop"}
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
super().__init__(dpcode, type_information)
self.options = [
ha_action
for ha_action, tuya_action in self._ACTION_MAPPINGS.items()
if tuya_action in type_information.range
]
def _convert_value_to_raw_value(self, device: CustomerDevice, value: str) -> str:
return self._ACTION_MAPPINGS[value]
class _SpecialInstructionEnumWrapper(_InstructionEnumWrapper):
"""Wrapper for enum-based instructions with special values (FZ/ZZ/STOP)."""
_ACTION_MAPPINGS = {"open": "FZ", "close": "ZZ", "stop": "STOP"}
class _IsClosedInvertedWrapper(DPCodeBooleanWrapper):
"""Boolean wrapper for checking if cover is closed (inverted)."""
def read_device_status(self, device: CustomerDevice) -> bool | None:
if (value := self._read_dpcode_value(device)) is None:
return None
return not value
class _IsClosedEnumWrapper(DPCodeEnumWrapper[bool]):
"""Enum wrapper for checking if state is closed."""
_MAPPINGS = {
"close": True,
"fully_close": True,
"open": False,
"fully_open": False,
}
def read_device_status(self, device: CustomerDevice) -> bool | None:
if (value := self._read_dpcode_value(device)) is None:
return None
return self._MAPPINGS.get(value)
@dataclass(frozen=True)
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe a Tuya cover entity."""
current_state: DPCode | tuple[DPCode, ...] | None = None
current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = (
_IsClosedEnumWrapper
)
current_state_wrapper: type[
DPCodeInvertedBooleanWrapper | CoverClosedEnumWrapper
] = CoverClosedEnumWrapper
current_position: DPCode | tuple[DPCode, ...] | None = None
instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
_InvertedPercentageMappingWrapper
)
instruction_wrapper: type[CoverInstructionEnumWrapper] = CoverInstructionEnumWrapper
position_wrapper: type[DPCodePercentageWrapper] = DPCodeInvertedPercentageWrapper
set_position: DPCode | None = None
@@ -162,7 +59,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_door",
translation_placeholders={"index": "1"},
current_state=DPCode.DOORCONTACT_STATE,
current_state_wrapper=_IsClosedInvertedWrapper,
current_state_wrapper=DPCodeInvertedBooleanWrapper,
device_class=CoverDeviceClass.GARAGE,
),
TuyaCoverEntityDescription(
@@ -170,7 +67,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_door",
translation_placeholders={"index": "2"},
current_state=DPCode.DOORCONTACT_STATE_2,
current_state_wrapper=_IsClosedInvertedWrapper,
current_state_wrapper=DPCodeInvertedBooleanWrapper,
device_class=CoverDeviceClass.GARAGE,
),
TuyaCoverEntityDescription(
@@ -178,7 +75,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_door",
translation_placeholders={"index": "3"},
current_state=DPCode.DOORCONTACT_STATE_3,
current_state_wrapper=_IsClosedInvertedWrapper,
current_state_wrapper=DPCodeInvertedBooleanWrapper,
device_class=CoverDeviceClass.GARAGE,
),
),
@@ -213,7 +110,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
current_position=DPCode.POSITION,
set_position=DPCode.POSITION,
device_class=CoverDeviceClass.CURTAIN,
instruction_wrapper=_SpecialInstructionEnumWrapper,
instruction_wrapper=CoverInstructionSpecialEnumWrapper,
),
# switch_1 is an undocumented code that behaves identically to control
# It is used by the Kogan Smart Blinds Driver
@@ -230,7 +127,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
key=DPCode.CONTROL,
translation_key="curtain",
current_position=DPCode.PERCENT_CONTROL,
position_wrapper=_ControlBackModePercentageMappingWrapper,
position_wrapper=ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL,
device_class=CoverDeviceClass.CURTAIN,
),
@@ -239,7 +136,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_curtain",
translation_placeholders={"index": "2"},
current_position=DPCode.PERCENT_CONTROL_2,
position_wrapper=_ControlBackModePercentageMappingWrapper,
position_wrapper=ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL_2,
device_class=CoverDeviceClass.CURTAIN,
),
@@ -266,7 +163,7 @@ def _get_instruction_wrapper(
return enum_wrapper
# Fallback to a boolean wrapper if available
return _InstructionBooleanWrapper.find_dpcode(
return CoverInstructionBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
@@ -338,7 +235,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
*,
current_position: DeviceWrapper[int] | None,
current_state_wrapper: DeviceWrapper[bool] | None,
instruction_wrapper: DeviceWrapper[str] | None,
instruction_wrapper: DeviceWrapper[TuyaCoverAction] | None,
set_position: DeviceWrapper[int] | None,
tilt_position: DeviceWrapper[int] | None,
) -> None:
@@ -355,11 +252,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._tilt_position = tilt_position
if instruction_wrapper:
if "open" in instruction_wrapper.options:
if TuyaCoverAction.OPEN in instruction_wrapper.options:
self._attr_supported_features |= CoverEntityFeature.OPEN
if "close" in instruction_wrapper.options:
if TuyaCoverAction.CLOSE in instruction_wrapper.options:
self._attr_supported_features |= CoverEntityFeature.CLOSE
if "stop" in instruction_wrapper.options:
if TuyaCoverAction.STOP in instruction_wrapper.options:
self._attr_supported_features |= CoverEntityFeature.STOP
if set_position:
@@ -399,10 +296,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if (
self._instruction_wrapper
and (options := self._instruction_wrapper.options)
and "open" in options
and TuyaCoverAction.OPEN in self._instruction_wrapper.options
):
await self._async_send_wrapper_updates(self._instruction_wrapper, "open")
await self._async_send_wrapper_updates(
self._instruction_wrapper, TuyaCoverAction.OPEN
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
@@ -414,10 +312,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
if (
self._instruction_wrapper
and (options := self._instruction_wrapper.options)
and "close" in options
and TuyaCoverAction.CLOSE in self._instruction_wrapper.options
):
await self._async_send_wrapper_updates(self._instruction_wrapper, "close")
await self._async_send_wrapper_updates(
self._instruction_wrapper, TuyaCoverAction.CLOSE
)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -427,8 +326,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
if (
self._instruction_wrapper
and TuyaCoverAction.STOP in self._instruction_wrapper.options
):
await self._async_send_wrapper_updates(
self._instruction_wrapper, TuyaCoverAction.STOP
)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""

View File

@@ -2,16 +2,15 @@
from __future__ import annotations
from base64 import b64decode
from dataclasses import dataclass
from typing import Any
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeEnumWrapper,
DPCodeRawWrapper,
DPCodeStringWrapper,
DPCodeTypeInformationWrapper,
from tuya_device_handlers.device_wrapper.common import DPCodeTypeInformationWrapper
from tuya_device_handlers.device_wrapper.event import (
Base64Utf8RawEventWrapper,
Base64Utf8StringEventWrapper,
SimpleEventEnumWrapper,
)
from tuya_sharing import CustomerDevice, Manager
@@ -29,58 +28,11 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
class _EventEnumWrapper(DPCodeEnumWrapper[tuple[str, None]]):
"""Wrapper for event enum DP codes."""
def read_device_status(self, device: CustomerDevice) -> tuple[str, None] | None:
"""Return the event details."""
if (raw_value := self._read_dpcode_value(device)) is None:
return None
return (raw_value, None)
class _AlarmMessageWrapper(DPCodeStringWrapper[tuple[str, dict[str, Any]]]):
"""Wrapper for a STRING message on DPCode.ALARM_MESSAGE."""
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _AlarmMessageWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the alarm message."""
if (raw_value := self._read_dpcode_value(device)) is None:
return None
return ("triggered", {"message": b64decode(raw_value).decode("utf-8")})
class _DoorbellPicWrapper(DPCodeRawWrapper[tuple[str, dict[str, Any]]]):
"""Wrapper for a RAW message on DPCode.DOORBELL_PIC.
It is expected that the RAW data is base64/utf8 encoded URL of the picture.
"""
def __init__(self, dpcode: str, type_information: Any) -> None:
"""Init _DoorbellPicWrapper."""
super().__init__(dpcode, type_information)
self.options = ["triggered"]
def read_device_status(
self, device: CustomerDevice
) -> tuple[str, dict[str, Any]] | None:
"""Return the event attributes for the doorbell picture."""
if (status := self._read_dpcode_value(device)) is None:
return None
return ("triggered", {"message": status.decode("utf-8")})
@dataclass(frozen=True)
class TuyaEventEntityDescription(EventEntityDescription):
"""Describe a Tuya Event entity."""
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
wrapper_class: type[DPCodeTypeInformationWrapper] = SimpleEventEnumWrapper
# All descriptions can be found here. Mostly the Enum data types in the
@@ -92,13 +44,13 @@ EVENTS: dict[DeviceCategory, tuple[TuyaEventEntityDescription, ...]] = {
key=DPCode.ALARM_MESSAGE,
device_class=EventDeviceClass.DOORBELL,
translation_key="doorbell_message",
wrapper_class=_AlarmMessageWrapper,
wrapper_class=Base64Utf8StringEventWrapper,
),
TuyaEventEntityDescription(
key=DPCode.DOORBELL_PIC,
device_class=EventDeviceClass.DOORBELL,
translation_key="doorbell_picture",
wrapper_class=_DoorbellPicWrapper,
wrapper_class=Base64Utf8RawEventWrapper,
),
),
DeviceCategory.WXKG: (

View File

@@ -8,10 +8,13 @@ from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from tuya_device_handlers.type_information import IntegerTypeInformation
from tuya_device_handlers.utils import RemapHelper
from tuya_device_handlers.device_wrapper.fan import (
FanDirectionEnumWrapper,
FanSpeedEnumWrapper,
FanSpeedIntegerWrapper,
)
from tuya_device_handlers.helpers.homeassistant import TuyaFanDirection
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.fan import (
@@ -23,10 +26,6 @@ from homeassistant.components.fan import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
@@ -53,18 +52,13 @@ TUYA_SUPPORT_TYPE: set[DeviceCategory] = {
DeviceCategory.KS,
}
class _DirectionEnumWrapper(DPCodeEnumWrapper):
"""Wrapper for fan direction DP code."""
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device status and return the direction string."""
if (value := self._read_dpcode_value(device)) and value in {
DIRECTION_FORWARD,
DIRECTION_REVERSE,
}:
return value
return None
_TUYA_TO_HA_DIRECTION_MAPPINGS = {
TuyaFanDirection.FORWARD: DIRECTION_FORWARD,
TuyaFanDirection.REVERSE: DIRECTION_REVERSE,
}
_HA_TO_TUYA_DIRECTION_MAPPINGS = {
v: k for k, v in _TUYA_TO_HA_DIRECTION_MAPPINGS.items()
}
def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
@@ -80,50 +74,15 @@ def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
return any(get_dpcode(device, code) for code in properties_to_check)
class _FanSpeedEnumWrapper(DPCodeEnumWrapper[int]):
"""Wrapper for fan speed DP code (from an enum)."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Get the current speed as a percentage."""
if (value := self._read_dpcode_value(device)) is None:
return None
return ordered_list_item_to_percentage(self.options, value)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value."""
return percentage_to_ordered_list_item(self.options, value)
class _FanSpeedIntegerWrapper(DPCodeIntegerWrapper[int]):
"""Wrapper for fan speed DP code (from an integer)."""
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self._remap_helper = RemapHelper.from_type_information(type_information, 1, 100)
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Get the current speed as a percentage."""
if (value := self._read_dpcode_value(device)) is None:
return None
return round(self._remap_helper.remap_value_to(value))
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value."""
return round(self._remap_helper.remap_value_from(value))
def _get_speed_wrapper(
device: CustomerDevice,
) -> _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | None:
) -> DeviceWrapper[int] | None:
"""Get the speed wrapper for the device."""
if int_wrapper := _FanSpeedIntegerWrapper.find_dpcode(
if int_wrapper := FanSpeedIntegerWrapper.find_dpcode(
device, _SPEED_DPCODES, prefer_function=True
):
return int_wrapper
return _FanSpeedEnumWrapper.find_dpcode(
device, _SPEED_DPCODES, prefer_function=True
)
return FanSpeedEnumWrapper.find_dpcode(device, _SPEED_DPCODES, prefer_function=True)
async def async_setup_entry(
@@ -145,7 +104,7 @@ async def async_setup_entry(
TuyaFanEntity(
device,
manager,
direction_wrapper=_DirectionEnumWrapper.find_dpcode(
direction_wrapper=FanDirectionEnumWrapper.find_dpcode(
device, _DIRECTION_DPCODES, prefer_function=True
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
@@ -179,7 +138,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
device: CustomerDevice,
device_manager: Manager,
*,
direction_wrapper: DeviceWrapper[str] | None,
direction_wrapper: DeviceWrapper[TuyaFanDirection] | None,
mode_wrapper: DeviceWrapper[str] | None,
oscillate_wrapper: DeviceWrapper[bool] | None,
speed_wrapper: DeviceWrapper[int] | None,
@@ -220,7 +179,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self._async_send_wrapper_updates(self._direction_wrapper, direction)
if tuya_value := _HA_TO_TUYA_DIRECTION_MAPPINGS.get(direction):
await self._async_send_wrapper_updates(self._direction_wrapper, tuya_value)
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
@@ -265,7 +225,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
@property
def current_direction(self) -> str | None:
"""Return the current direction of the fan."""
return self._read_wrapper(self._direction_wrapper)
tuya_value = self._read_wrapper(self._direction_wrapper)
return _TUYA_TO_HA_DIRECTION_MAPPINGS.get(tuya_value) if tuya_value else None
@property
def oscillating(self) -> bool | None:

View File

@@ -9,8 +9,8 @@ from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.humidifier import (
@@ -29,16 +29,6 @@ from .entity import TuyaEntity
from .util import ActionDPCodeNotFoundError, get_dpcode
class _RoundedIntegerWrapper(DPCodeIntegerWrapper[int]):
"""An integer that always rounds its value."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Read and round the device status."""
if (value := self._read_dpcode_value(device)) is None:
return None
return round(value)
@dataclass(frozen=True)
class TuyaHumidifierEntityDescription(HumidifierEntityDescription):
"""Describe an Tuya (de)humidifier entity."""
@@ -104,7 +94,7 @@ async def async_setup_entry(
device,
manager,
description,
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
device, description.current_humidity
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
@@ -115,7 +105,7 @@ async def async_setup_entry(
description.dpcode or description.key,
prefer_function=True,
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
device, description.humidity, prefer_function=True
),
)

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import json
from typing import Any, cast
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
@@ -12,9 +11,15 @@ from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
DPCodeJsonWrapper,
)
from tuya_device_handlers.type_information import IntegerTypeInformation
from tuya_device_handlers.device_wrapper.light import (
DEFAULT_H_TYPE_V2,
DEFAULT_S_TYPE_V2,
DEFAULT_V_TYPE_V2,
BrightnessWrapper,
ColorDataWrapper,
ColorTempWrapper,
)
from tuya_device_handlers.utils import RemapHelper
from tuya_sharing import CustomerDevice, Manager
@@ -33,7 +38,6 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads_object
from . import TuyaConfigEntry
@@ -41,169 +45,6 @@ from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, WorkMode
from .entity import TuyaEntity
class _BrightnessWrapper(DPCodeIntegerWrapper[int]):
"""Wrapper for brightness DP code.
Handles brightness value conversion between device scale and Home Assistant's
0-255 scale. Supports optional dynamic brightness_min and brightness_max
wrappers that allow the device to specify runtime brightness range limits.
"""
brightness_min: DPCodeIntegerWrapper | None = None
brightness_max: DPCodeIntegerWrapper | None = None
brightness_min_remap: RemapHelper | None = None
brightness_max_remap: RemapHelper | None = None
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self._remap_helper = RemapHelper.from_type_information(type_information, 0, 255)
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Return the brightness of this light between 0..255."""
if (brightness := device.status.get(self.dpcode)) is None:
return None
# Remap value to our scale
brightness = self._remap_helper.remap_value_to(brightness)
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self.brightness_max is not None
and self.brightness_min is not None
and self.brightness_max_remap is not None
and self.brightness_min_remap is not None
and (brightness_max := device.status.get(self.brightness_max.dpcode))
is not None
and (brightness_min := device.status.get(self.brightness_min.dpcode))
is not None
):
# Remap values onto our scale
brightness_max = self.brightness_max_remap.remap_value_to(brightness_max)
brightness_min = self.brightness_min_remap.remap_value_to(brightness_min)
# Remap the brightness value from their min-max to our 0-255 scale
brightness = RemapHelper.remap_value(
brightness,
from_min=brightness_min,
from_max=brightness_max,
to_min=0,
to_max=255,
)
return round(brightness)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value (0..255) back to a raw device value."""
# If there is a min/max value, the brightness is actually limited.
# Meaning it is actually not on a 0-255 scale.
if (
self.brightness_max is not None
and self.brightness_min is not None
and self.brightness_max_remap is not None
and self.brightness_min_remap is not None
and (brightness_max := device.status.get(self.brightness_max.dpcode))
is not None
and (brightness_min := device.status.get(self.brightness_min.dpcode))
is not None
):
# Remap values onto our scale
brightness_max = self.brightness_max_remap.remap_value_to(brightness_max)
brightness_min = self.brightness_min_remap.remap_value_to(brightness_min)
# Remap the brightness value from our 0-255 scale to their min-max
value = RemapHelper.remap_value(
value,
from_min=0,
from_max=255,
to_min=brightness_min,
to_max=brightness_max,
)
return round(self._remap_helper.remap_value_from(value))
class _ColorTempWrapper(DPCodeIntegerWrapper[int]):
"""Wrapper for color temperature DP code."""
def __init__(self, dpcode: str, type_information: IntegerTypeInformation) -> None:
"""Init DPCodeIntegerWrapper."""
super().__init__(dpcode, type_information)
self._remap_helper = RemapHelper.from_type_information(
type_information, MIN_MIREDS, MAX_MIREDS
)
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Return the color temperature value in Kelvin."""
if (temperature := device.status.get(self.dpcode)) is None:
return None
return color_util.color_temperature_mired_to_kelvin(
self._remap_helper.remap_value_to(temperature, reverse=True)
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value (Kelvin) back to a raw device value."""
return round(
self._remap_helper.remap_value_from(
color_util.color_temperature_kelvin_to_mired(value), reverse=True
)
)
DEFAULT_H_TYPE = RemapHelper(source_min=1, source_max=360, target_min=0, target_max=360)
DEFAULT_S_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=100)
DEFAULT_V_TYPE = RemapHelper(source_min=1, source_max=255, target_min=0, target_max=255)
DEFAULT_H_TYPE_V2 = RemapHelper(
source_min=1, source_max=360, target_min=0, target_max=360
)
DEFAULT_S_TYPE_V2 = RemapHelper(
source_min=1, source_max=1000, target_min=0, target_max=100
)
DEFAULT_V_TYPE_V2 = RemapHelper(
source_min=1, source_max=1000, target_min=0, target_max=255
)
class _ColorDataWrapper(DPCodeJsonWrapper[tuple[float, float, float]]):
"""Wrapper for color data DP code."""
h_type = DEFAULT_H_TYPE
s_type = DEFAULT_S_TYPE
v_type = DEFAULT_V_TYPE
def read_device_status(
self, device: CustomerDevice
) -> tuple[float, float, float] | None:
"""Return a tuple (H, S, V) from this color data."""
if (status := self._read_dpcode_value(device)) is None:
return None
return (
self.h_type.remap_value_to(status["h"]),
self.s_type.remap_value_to(status["s"]),
self.v_type.remap_value_to(status["v"]),
)
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: tuple[float, float, float]
) -> Any:
"""Convert a Home Assistant tuple (H, S, V) back to a raw device value."""
hue, saturation, brightness = value
return json.dumps(
{
"h": round(self.h_type.remap_value_from(hue)),
"s": round(self.s_type.remap_value_from(saturation)),
"v": round(self.v_type.remap_value_from(brightness)),
}
)
MAX_MIREDS = 500 # 2000 K
MIN_MIREDS = 153 # 6500 K
class FallbackColorDataMode(StrEnum):
"""Fallback color data mode."""
@@ -551,9 +392,9 @@ LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ]
def _get_brightness_wrapper(
device: CustomerDevice, description: TuyaLightEntityDescription
) -> _BrightnessWrapper | None:
) -> BrightnessWrapper | None:
if (
brightness_wrapper := _BrightnessWrapper.find_dpcode(
brightness_wrapper := BrightnessWrapper.find_dpcode(
device, description.brightness, prefer_function=True
)
) is None:
@@ -578,10 +419,10 @@ def _get_brightness_wrapper(
def _get_color_data_wrapper(
device: CustomerDevice,
description: TuyaLightEntityDescription,
brightness_wrapper: _BrightnessWrapper | None,
) -> _ColorDataWrapper | None:
brightness_wrapper: BrightnessWrapper | None,
) -> ColorDataWrapper | None:
if (
color_data_wrapper := _ColorDataWrapper.find_dpcode(
color_data_wrapper := ColorDataWrapper.find_dpcode(
device, description.color_data, prefer_function=True
)
) is None:
@@ -643,7 +484,7 @@ async def async_setup_entry(
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.color_mode, prefer_function=True
),
color_temp_wrapper=_ColorTempWrapper.find_dpcode(
color_temp_wrapper=ColorTempWrapper.find_dpcode(
device, description.color_temp, prefer_function=True
),
switch_wrapper=switch_wrapper,

View File

@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.12",
"tuya-device-handlers==0.0.13",
"tuya-device-sharing-sdk==0.2.8"
]
}

View File

@@ -2,12 +2,17 @@
from __future__ import annotations
from typing import Any, Self
from typing import Any
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
from tuya_device_handlers.device_wrapper.common import DPCodeEnumWrapper
from tuya_device_handlers.device_wrapper.vacuum import (
VacuumActionWrapper,
VacuumActivityWrapper,
)
from tuya_device_handlers.helpers.homeassistant import (
TuyaVacuumAction,
TuyaVacuumActivity,
)
from tuya_sharing import CustomerDevice, Manager
@@ -24,138 +29,14 @@ from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
class _VacuumActivityWrapper(DeviceWrapper[VacuumActivity]):
"""Wrapper for the state of a device."""
_TUYA_STATUS_TO_HA = {
"charge_done": VacuumActivity.DOCKED,
"chargecompleted": VacuumActivity.DOCKED,
"chargego": VacuumActivity.DOCKED,
"charging": VacuumActivity.DOCKED,
"cleaning": VacuumActivity.CLEANING,
"docking": VacuumActivity.RETURNING,
"goto_charge": VacuumActivity.RETURNING,
"goto_pos": VacuumActivity.CLEANING,
"mop_clean": VacuumActivity.CLEANING,
"part_clean": VacuumActivity.CLEANING,
"paused": VacuumActivity.PAUSED,
"pick_zone_clean": VacuumActivity.CLEANING,
"pos_arrived": VacuumActivity.CLEANING,
"pos_unarrive": VacuumActivity.CLEANING,
"random": VacuumActivity.CLEANING,
"sleep": VacuumActivity.IDLE,
"smart_clean": VacuumActivity.CLEANING,
"smart": VacuumActivity.CLEANING,
"spot_clean": VacuumActivity.CLEANING,
"standby": VacuumActivity.IDLE,
"wall_clean": VacuumActivity.CLEANING,
"wall_follow": VacuumActivity.CLEANING,
"zone_clean": VacuumActivity.CLEANING,
}
def __init__(
self,
pause_wrapper: DPCodeBooleanWrapper | None = None,
status_wrapper: DPCodeEnumWrapper | None = None,
) -> None:
"""Init _VacuumActivityWrapper."""
self._pause_wrapper = pause_wrapper
self._status_wrapper = status_wrapper
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
"""Find and return a _VacuumActivityWrapper for the given DP codes."""
pause_wrapper = DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE)
status_wrapper = DPCodeEnumWrapper.find_dpcode(device, DPCode.STATUS)
if pause_wrapper or status_wrapper:
return cls(pause_wrapper=pause_wrapper, status_wrapper=status_wrapper)
return None
def read_device_status(self, device: CustomerDevice) -> VacuumActivity | None:
"""Read the device status."""
if (
self._status_wrapper
and (status := self._status_wrapper.read_device_status(device)) is not None
):
return self._TUYA_STATUS_TO_HA.get(status)
if self._pause_wrapper and self._pause_wrapper.read_device_status(device):
return VacuumActivity.PAUSED
return None
class _VacuumActionWrapper(DeviceWrapper):
"""Wrapper for sending actions to a vacuum."""
_TUYA_MODE_RETURN_HOME = "chargego"
def __init__(
self,
charge_wrapper: DPCodeBooleanWrapper | None,
locate_wrapper: DPCodeBooleanWrapper | None,
pause_wrapper: DPCodeBooleanWrapper | None,
mode_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
) -> None:
"""Init _VacuumActionWrapper."""
self._charge_wrapper = charge_wrapper
self._locate_wrapper = locate_wrapper
self._mode_wrapper = mode_wrapper
self._switch_wrapper = switch_wrapper
self.options = []
if charge_wrapper or (
mode_wrapper and self._TUYA_MODE_RETURN_HOME in mode_wrapper.options
):
self.options.append("return_to_base")
if locate_wrapper:
self.options.append("locate")
if pause_wrapper:
self.options.append("pause")
if switch_wrapper:
self.options.append("start")
self.options.append("stop")
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self:
"""Find and return a _VacuumActionWrapper for the given DP codes."""
return cls(
charge_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_CHARGE, prefer_function=True
),
locate_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SEEK, prefer_function=True
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
pause_wrapper=DPCodeBooleanWrapper.find_dpcode(device, DPCode.PAUSE),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.POWER_GO, prefer_function=True
),
)
def get_update_commands(
self, device: CustomerDevice, value: Any
) -> list[dict[str, Any]]:
"""Get the commands for the action wrapper."""
if value == "locate" and self._locate_wrapper:
return self._locate_wrapper.get_update_commands(device, True)
if value == "pause" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, False)
if value == "return_to_base":
if self._charge_wrapper:
return self._charge_wrapper.get_update_commands(device, True)
if self._mode_wrapper:
return self._mode_wrapper.get_update_commands(
device, self._TUYA_MODE_RETURN_HOME
)
if value == "start" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, True)
if value == "stop" and self._switch_wrapper:
return self._switch_wrapper.get_update_commands(device, False)
return []
_TUYA_TO_HA_ACTIVITY_MAPPINGS = {
TuyaVacuumActivity.CLEANING: VacuumActivity.CLEANING,
TuyaVacuumActivity.DOCKED: VacuumActivity.DOCKED,
TuyaVacuumActivity.IDLE: VacuumActivity.IDLE,
TuyaVacuumActivity.PAUSED: VacuumActivity.PAUSED,
TuyaVacuumActivity.RETURNING: VacuumActivity.RETURNING,
TuyaVacuumActivity.ERROR: VacuumActivity.ERROR,
}
async def async_setup_entry(
@@ -177,8 +58,8 @@ async def async_setup_entry(
TuyaVacuumEntity(
device,
manager,
action_wrapper=_VacuumActionWrapper.find_dpcode(device),
activity_wrapper=_VacuumActivityWrapper.find_dpcode(device),
action_wrapper=VacuumActionWrapper.find_dpcode(device),
activity_wrapper=VacuumActivityWrapper.find_dpcode(device),
fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.SUCTION, prefer_function=True
),
@@ -203,8 +84,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
device: CustomerDevice,
device_manager: Manager,
*,
action_wrapper: DeviceWrapper[str] | None,
activity_wrapper: DeviceWrapper[VacuumActivity] | None,
action_wrapper: DeviceWrapper[TuyaVacuumAction] | None,
activity_wrapper: DeviceWrapper[TuyaVacuumActivity] | None,
fan_speed_wrapper: DeviceWrapper[str] | None,
) -> None:
"""Init Tuya vacuum."""
@@ -217,15 +98,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
if action_wrapper:
if "pause" in action_wrapper.options:
if TuyaVacuumAction.PAUSE in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.PAUSE
if "return_to_base" in action_wrapper.options:
if TuyaVacuumAction.RETURN_TO_BASE in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
if "locate" in action_wrapper.options:
if TuyaVacuumAction.LOCATE in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.LOCATE
if "start" in action_wrapper.options:
if TuyaVacuumAction.START in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.START
if "stop" in action_wrapper.options:
if TuyaVacuumAction.STOP in action_wrapper.options:
self._attr_supported_features |= VacuumEntityFeature.STOP
if activity_wrapper:
@@ -243,27 +124,38 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
@property
def activity(self) -> VacuumActivity | None:
"""Return Tuya vacuum device state."""
return self._read_wrapper(self._activity_wrapper)
tuya_value = self._read_wrapper(self._activity_wrapper)
return _TUYA_TO_HA_ACTIVITY_MAPPINGS.get(tuya_value) if tuya_value else None
async def async_start(self, **kwargs: Any) -> None:
"""Start the device."""
await self._async_send_wrapper_updates(self._action_wrapper, "start")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaVacuumAction.START
)
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the device."""
await self._async_send_wrapper_updates(self._action_wrapper, "stop")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaVacuumAction.STOP
)
async def async_pause(self, **kwargs: Any) -> None:
"""Pause the device."""
await self._async_send_wrapper_updates(self._action_wrapper, "pause")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaVacuumAction.PAUSE
)
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Return device to dock."""
await self._async_send_wrapper_updates(self._action_wrapper, "return_to_base")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaVacuumAction.RETURN_TO_BASE
)
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the device."""
await self._async_send_wrapper_updates(self._action_wrapper, "locate")
await self._async_send_wrapper_updates(
self._action_wrapper, TuyaVacuumAction.LOCATE
)
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""

View File

@@ -3,11 +3,11 @@
from aiodns.error import DNSError
from aiohttp.client_exceptions import ClientConnectionError
from uhooapi import Client
from uhooapi.errors import UhooError, UnauthorizedError
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
@@ -28,8 +28,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: UhooConfigEntry)
await client.setup_devices()
except (ClientConnectionError, DNSError) as err:
raise ConfigEntryNotReady(f"Cannot connect to uHoo servers: {err}") from err
except UnauthorizedError as err:
raise ConfigEntryError(f"Invalid API credentials: {err}") from err
except (UnauthorizedError, ForbiddenError) as err:
raise ConfigEntryAuthFailed(f"Invalid API credentials: {err}") from err
except UhooError as err:
raise ConfigEntryNotReady(err) from err

View File

@@ -1,9 +1,10 @@
"""Custom uhoo config flow setup."""
from collections.abc import Mapping
from typing import Any
from uhooapi import Client
from uhooapi.errors import UhooError, UnauthorizedError
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -45,7 +46,7 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN):
client = Client(user_input[CONF_API_KEY], session, debug=True)
try:
await client.login()
except UnauthorizedError:
except UnauthorizedError, ForbiddenError:
errors["base"] = "invalid_auth"
except UhooError:
errors["base"] = "cannot_connect"
@@ -65,3 +66,39 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
if user_input is not None:
session = async_create_clientsession(self.hass)
client = Client(user_input[CONF_API_KEY], session, debug=True)
try:
await client.login()
except UnauthorizedError, ForbiddenError:
errors["base"] = "invalid_auth"
except UhooError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
USER_DATA_SCHEMA, user_input
),
errors=errors,
)

View File

@@ -1,10 +1,11 @@
"""Custom uhoo data update coordinator."""
from uhooapi import Client, Device
from uhooapi.errors import UhooError
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
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, LOGGER, UPDATE_INTERVAL
@@ -34,6 +35,8 @@ class UhooDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
if self.client.devices:
for device_id in self.client.devices:
await self.client.get_latest_data(device_id)
except (UnauthorizedError, ForbiddenError) as error:
raise ConfigEntryAuthFailed(f"Invalid API credentials: {error}") from error
except UhooError as error:
raise UpdateFailed(f"The device is unavailable: {error}") from error
else:

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/uhooair",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["uhooapi==1.2.8"]
}

View File

@@ -26,9 +26,9 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold

View File

@@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +10,14 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "Your uHoo API key. You can find this in your uHoo account settings."
}
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"

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