mirror of
https://github.com/home-assistant/core.git
synced 2026-03-16 16:02:06 +01:00
Compare commits
1 Commits
dev
...
fritz/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c8268f9d8 |
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Download Translations
|
||||
run: python3 -m script.translations download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Archive translations
|
||||
shell: bash
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -1400,7 +1400,7 @@ jobs:
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
flags: full-suite
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
pytest-partial:
|
||||
name: Run tests Python ${{ matrix.python-version }} (${{ matrix.group }})
|
||||
@@ -1570,7 +1570,7 @@ jobs:
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
upload-test-results:
|
||||
name: Upload test results to Codecov
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
# v1.7.0
|
||||
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a
|
||||
with:
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} # zizmor: ignore[secrets-outside-env]
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} # zizmor: ignore[secrets-outside-env]
|
||||
app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }}
|
||||
private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }}
|
||||
|
||||
# The 90 day stale policy for issues
|
||||
# Used for:
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -33,6 +33,6 @@ jobs:
|
||||
|
||||
- name: Upload Translations
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }} # zizmor: ignore[secrets-outside-env]
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
run: |
|
||||
python3 -m script.translations upload
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -142,7 +142,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;multidict;propcache;yarl;SQLAlchemy
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
abi: ${{ matrix.abi }}
|
||||
tag: musllinux_1_2
|
||||
arch: ${{ matrix.arch }}
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
wheels-key: ${{ secrets.WHEELS_KEY }}
|
||||
env-file: true
|
||||
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
|
||||
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
|
||||
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types: [csv, json, html]
|
||||
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.23.1
|
||||
rev: v1.22.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
@@ -17,7 +17,7 @@ from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRunti
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Arcam binary sensors for incoming stream info."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ArcamFmjConfigEntry
|
||||
from .entity import ArcamFmjEntity
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ArcamFmjBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes an Arcam FMJ binary sensor entity."""
|
||||
|
||||
value_fn: Callable[[State], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSORS: tuple[ArcamFmjBinarySensorEntityDescription, ...] = (
|
||||
ArcamFmjBinarySensorEntityDescription(
|
||||
key="incoming_video_interlaced",
|
||||
translation_key="incoming_video_interlaced",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda state: (
|
||||
vp.interlaced
|
||||
if (vp := state.get_incoming_video_parameters()) is not None
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ArcamFmjConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Arcam FMJ binary sensors from a config entry."""
|
||||
coordinators = config_entry.runtime_data.coordinators
|
||||
|
||||
entities: list[ArcamFmjBinarySensorEntity] = []
|
||||
for coordinator in coordinators.values():
|
||||
entities.extend(
|
||||
ArcamFmjBinarySensorEntity(coordinator, description)
|
||||
for description in BINARY_SENSORS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ArcamFmjBinarySensorEntity(ArcamFmjEntity, BinarySensorEntity):
|
||||
"""Representation of an Arcam FMJ binary sensor."""
|
||||
|
||||
entity_description: ArcamFmjBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the binary sensor value."""
|
||||
return self.entity_description.value_fn(self.coordinator.state)
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"default": "mdi:reorder-horizontal"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"default": "mdi:surround-sound"
|
||||
},
|
||||
"incoming_audio_format": {
|
||||
"default": "mdi:dolby"
|
||||
},
|
||||
"incoming_audio_sample_rate": {
|
||||
"default": "mdi:waveform"
|
||||
},
|
||||
"incoming_video_aspect_ratio": {
|
||||
"default": "mdi:aspect-ratio"
|
||||
},
|
||||
"incoming_video_colorspace": {
|
||||
"default": "mdi:palette"
|
||||
},
|
||||
"incoming_video_horizontal_resolution": {
|
||||
"default": "mdi:arrow-expand-horizontal"
|
||||
},
|
||||
"incoming_video_refresh_rate": {
|
||||
"default": "mdi:animation"
|
||||
},
|
||||
"incoming_video_vertical_resolution": {
|
||||
"default": "mdi:arrow-expand-vertical"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"incoming_video_interlaced": {
|
||||
"name": "Incoming video interlaced"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"incoming_audio_config": {
|
||||
"name": "Incoming audio configuration",
|
||||
|
||||
@@ -78,13 +78,19 @@ class AssistPipelineSelect(SelectEntity, restore_state.RestoreEntity):
|
||||
index: int = 0,
|
||||
) -> None:
|
||||
"""Initialize a pipeline selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline_{index + 1}",
|
||||
translation_key="pipeline_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"pipeline{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
self._domain = domain
|
||||
self._unique_id_prefix = unique_id_prefix
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assistant",
|
||||
"name": "Assistant{index}",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "Assistant {index}",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "Finished speaking detection",
|
||||
"state": {
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.10.2"
|
||||
"habluetooth==5.9.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,
|
||||
)
|
||||
@@ -46,11 +47,11 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.COOLING
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
),
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
|
||||
@@ -79,8 +80,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
HVACMode.HEAT_COOL,
|
||||
},
|
||||
),
|
||||
"started_heating": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
|
||||
"started_heating": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiocomelit==2.0.1"]
|
||||
"requirements": ["aiocomelit==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.5.2",
|
||||
"aioesphomeapi==44.3.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -123,13 +123,19 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
|
||||
def __init__(self, entry_data: RuntimeEntryData, index: int = 0) -> None:
|
||||
"""Initialize a wake word selector."""
|
||||
if index >= 1:
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word_{index + 1}",
|
||||
translation_key="wake_word_n",
|
||||
translation_placeholders={"index": str(index + 1)},
|
||||
)
|
||||
if index < 1:
|
||||
# Keep compatibility
|
||||
key_suffix = ""
|
||||
placeholder = ""
|
||||
else:
|
||||
key_suffix = f"_{index + 1}"
|
||||
placeholder = f" {index + 1}"
|
||||
|
||||
self.entity_description = replace(
|
||||
self.entity_description,
|
||||
key=f"wake_word{key_suffix}",
|
||||
translation_placeholders={"index": placeholder},
|
||||
)
|
||||
|
||||
EsphomeAssistEntity.__init__(self, entry_data)
|
||||
|
||||
|
||||
@@ -107,12 +107,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
@@ -122,18 +116,11 @@
|
||||
}
|
||||
},
|
||||
"wake_word": {
|
||||
"name": "Wake word",
|
||||
"name": "Wake word{index}",
|
||||
"state": {
|
||||
"no_wake_word": "No wake word",
|
||||
"okay_nabu": "Okay Nabu"
|
||||
}
|
||||
},
|
||||
"wake_word_n": {
|
||||
"name": "Wake word {index}",
|
||||
"state": {
|
||||
"no_wake_word": "[%key:component::esphome::entity::select::wake_word::state::no_wake_word%]",
|
||||
"okay_nabu": "[%key:component::esphome::entity::select::wake_word::state::okay_nabu%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["fritzconnection"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,9 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: we are close to the goal of 95%
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -133,20 +133,26 @@ async def _async_wifi_entities_list(
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("WiFi networks count: %s", wifi_count)
|
||||
networks: dict[int, dict[str, Any]] = {}
|
||||
networks: dict = {}
|
||||
for i in range(1, wifi_count + 1):
|
||||
network_info = await avm_wrapper.async_get_wlan_configuration(i)
|
||||
# Devices with 4 WLAN services, use the 2nd for internal communications
|
||||
if not (wifi_count == 4 and i == 2):
|
||||
networks[i] = network_info
|
||||
networks[i] = {
|
||||
"ssid": network_info["NewSSID"],
|
||||
"bssid": network_info["NewBSSID"],
|
||||
"standard": network_info["NewStandard"],
|
||||
"enabled": network_info["NewEnable"],
|
||||
"status": network_info["NewStatus"],
|
||||
}
|
||||
for i, network in networks.copy().items():
|
||||
networks[i]["switch_name"] = network["NewSSID"]
|
||||
networks[i]["switch_name"] = network["ssid"]
|
||||
if (
|
||||
len(
|
||||
[
|
||||
j
|
||||
for j, n in networks.items()
|
||||
if slugify(n["NewSSID"]) == slugify(network["NewSSID"])
|
||||
if slugify(n["ssid"]) == slugify(network["ssid"])
|
||||
]
|
||||
)
|
||||
> 1
|
||||
@@ -428,11 +434,13 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch):
|
||||
for key, attr in attributes_dict.items():
|
||||
self._attributes[attr] = self.port_mapping[key]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> bool:
|
||||
self.port_mapping["NewEnabled"] = "1" if turn_on else "0"
|
||||
await self._avm_wrapper.async_add_port_mapping(
|
||||
|
||||
resp = await self._avm_wrapper.async_add_port_mapping(
|
||||
self.connection_type, self.port_mapping
|
||||
)
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
@@ -517,11 +525,12 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> bool:
|
||||
"""Handle switch state change request."""
|
||||
await self._avm_wrapper.async_set_allow_wan_access(self.ip_address, turn_on)
|
||||
self._avm_wrapper.devices[self._mac].wan_access = turn_on
|
||||
self.async_write_ha_state()
|
||||
return True
|
||||
|
||||
|
||||
class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
@@ -532,11 +541,10 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
network_num: int,
|
||||
network_data: dict[str, Any],
|
||||
network_data: dict,
|
||||
) -> None:
|
||||
"""Init Fritz Wifi switch."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
self._wifi_info = network_data
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
@@ -552,7 +560,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
type=SWITCH_TYPE_WIFINETWORK,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
init_state=network_data["NewEnable"],
|
||||
init_state=network_data["enabled"],
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
|
||||
@@ -579,9 +587,7 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
self._attributes["mac_address_control"] = wifi_info[
|
||||
"NewMACAddressControlEnabled"
|
||||
]
|
||||
self._wifi_info = wifi_info
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
"""Handle wifi switch."""
|
||||
self._wifi_info["NewEnable"] = turn_on
|
||||
await self._avm_wrapper.async_set_wlan_configuration(self._network_num, turn_on)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.2"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ RESPONSE_HEADERS_FILTER = {
|
||||
}
|
||||
|
||||
MIN_COMPRESSED_SIZE = 128
|
||||
MAX_WEBSOCKET_MESSAGE_SIZE = 16 * 1024 * 1024 # 16 MiB
|
||||
MAX_SIMPLE_RESPONSE_SIZE = 4194000
|
||||
|
||||
DISABLED_TIMEOUT = ClientTimeout(total=None)
|
||||
@@ -127,10 +126,7 @@ class HassIOIngress(HomeAssistantView):
|
||||
req_protocols = ()
|
||||
|
||||
ws_server = web.WebSocketResponse(
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
protocols=req_protocols, autoclose=False, autoping=False
|
||||
)
|
||||
await ws_server.prepare(request)
|
||||
|
||||
@@ -153,7 +149,6 @@ class HassIOIngress(HomeAssistantView):
|
||||
protocols=req_protocols,
|
||||
autoclose=False,
|
||||
autoping=False,
|
||||
max_msg_size=MAX_WEBSOCKET_MESSAGE_SIZE,
|
||||
) as ws_client:
|
||||
# Proxy requests
|
||||
await asyncio.wait(
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.4.1"],
|
||||
"requirements": ["aiohasupervisor==0.3.3"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@
|
||||
"description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Connectivity check disabled"
|
||||
},
|
||||
"unsupported_content_trust": {
|
||||
"description": "System is unsupported because Home Assistant cannot verify content being run is trusted and not modified by attackers. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Content-trust check disabled"
|
||||
},
|
||||
"unsupported_dbus": {
|
||||
"description": "System is unsupported because D-Bus is working incorrectly. Many things fail without this as Supervisor cannot communicate with the host. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - D-Bus issues"
|
||||
@@ -277,6 +281,10 @@
|
||||
"description": "System is unsupported because additional software outside the Home Assistant ecosystem has been detected. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Unsupported software"
|
||||
},
|
||||
"unsupported_source_mods": {
|
||||
"description": "System is unsupported because Supervisor source code has been modified. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor source modifications"
|
||||
},
|
||||
"unsupported_supervisor_version": {
|
||||
"description": "System is unsupported because an out-of-date version of Supervisor is in use and auto-update has been disabled. For troubleshooting information, select Learn more.",
|
||||
"title": "Unsupported system - Supervisor version"
|
||||
|
||||
@@ -38,7 +38,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.FAN,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
"""Provides climate entities for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_AUTO,
|
||||
ClimateEntity,
|
||||
ClimateEntityDescription,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
HVAC_MODES_PROGRAMS_MAP = {
|
||||
HVACMode.AUTO: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_AUTO,
|
||||
HVACMode.COOL: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_COOL,
|
||||
HVACMode.DRY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_DRY,
|
||||
HVACMode.FAN_ONLY: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN,
|
||||
HVACMode.HEAT: ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_HEAT,
|
||||
}
|
||||
|
||||
PROGRAMS_HVAC_MODES_MAP = {v: k for k, v in HVAC_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
PRESET_MODES_PROGRAMS_MAP = {
|
||||
"active_clean": ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN,
|
||||
}
|
||||
PROGRAMS_PRESET_MODES_MAP = {v: k for k, v in PRESET_MODES_PROGRAMS_MAP.items()}
|
||||
|
||||
FAN_MODES_OPTIONS = {
|
||||
FAN_AUTO: "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
|
||||
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
|
||||
}
|
||||
|
||||
FAN_MODES_OPTIONS_INVERTED = {v: k for k, v in FAN_MODES_OPTIONS.items()}
|
||||
|
||||
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION = ClimateEntityDescription(
|
||||
key="air_conditioner",
|
||||
translation_key="air_conditioner",
|
||||
name=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
appliance_coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
[HomeConnectAirConditioningEntity(appliance_coordinator)]
|
||||
if (programs := appliance_coordinator.data.programs)
|
||||
and any(
|
||||
program.key in PROGRAMS_HVAC_MODES_MAP
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
for program in programs
|
||||
)
|
||||
else []
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect climate entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectAirConditioningEntity(HomeConnectEntity, ClimateEntity):
|
||||
"""Representation of a Home Connect climate entity."""
|
||||
|
||||
# Note: The base class requires this to be set even though this
|
||||
# class doesn't support any temperature related functionality.
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectApplianceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
AIR_CONDITIONER_ENTITY_DESCRIPTION,
|
||||
context_override=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available hvac operation modes."""
|
||||
hvac_modes = [
|
||||
hvac_mode
|
||||
for program in self.appliance.programs
|
||||
if (hvac_mode := PROGRAMS_HVAC_MODES_MAP.get(program.key))
|
||||
and (
|
||||
program.constraints is None
|
||||
or program.constraints.execution
|
||||
in (Execution.SELECT_AND_START, Execution.START_ONLY)
|
||||
)
|
||||
]
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
hvac_modes.append(HVACMode.OFF)
|
||||
return hvac_modes
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return a list of available preset modes."""
|
||||
return (
|
||||
[
|
||||
PROGRAMS_PRESET_MODES_MAP[
|
||||
ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
]
|
||||
]
|
||||
if any(
|
||||
program.key
|
||||
is ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
for program in self.appliance.programs
|
||||
)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return the list of supported features."""
|
||||
features = ClimateEntityFeature(0)
|
||||
if SettingKey.BSH_COMMON_POWER_STATE in self.appliance.settings:
|
||||
features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
|
||||
if self.preset_modes:
|
||||
features |= ClimateEntityFeature.PRESET_MODE
|
||||
if self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
):
|
||||
features |= ClimateEntityFeature.FAN_MODE
|
||||
return features
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update_fan_mode(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug(
|
||||
"Updated %s (fan mode), new state: %s", self.entity_id, self.fan_mode
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self.async_write_ha_state,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update_fan_mode,
|
||||
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
)
|
||||
)
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
EventKey(SettingKey.BSH_COMMON_POWER_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the HVAC Mode and preset mode values."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
program_key = cast(ProgramKey, event.value) if event else None
|
||||
power_state = self.appliance.settings.get(SettingKey.BSH_COMMON_POWER_STATE)
|
||||
self._attr_hvac_mode = (
|
||||
HVACMode.OFF
|
||||
if power_state is not None and power_state.value != BSH_POWER_ON
|
||||
else PROGRAMS_HVAC_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
and program_key
|
||||
!= ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
self._attr_preset_mode = (
|
||||
PROGRAMS_PRESET_MODES_MAP.get(program_key)
|
||||
if program_key
|
||||
== ProgramKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_ACTIVE_CLEAN
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the fan setting."""
|
||||
option_value = None
|
||||
if event := self.appliance.events.get(
|
||||
EventKey(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
):
|
||||
option_value = event.value
|
||||
return (
|
||||
FAN_MODES_OPTIONS_INVERTED.get(cast(str, option_value))
|
||||
if option_value is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes."""
|
||||
if (
|
||||
(
|
||||
option_definition := self.appliance.options.get(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
|
||||
)
|
||||
)
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
):
|
||||
return [
|
||||
fan_mode
|
||||
for fan_mode, api_value in FAN_MODES_OPTIONS.items()
|
||||
if api_value in option_constraints.allowed_values
|
||||
]
|
||||
if option_definition:
|
||||
# Then the constraints or the allowed values are not present
|
||||
# So we stick to the default values
|
||||
return list(FAN_MODES_OPTIONS.keys())
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_ON,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_STANDBY,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"appliance_name": self.appliance.info.name,
|
||||
"value": BSH_POWER_STANDBY,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _set_program(self, program_key: ProgramKey) -> None:
|
||||
try:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": program_key.value,
|
||||
},
|
||||
) from err
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_turn_off()
|
||||
else:
|
||||
await self._set_program(HVAC_MODES_PROGRAMS_MAP[hvac_mode])
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
await self._set_program(PRESET_MODES_PROGRAMS_MAP[preset_mode])
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_MODES_OPTIONS[fan_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
)
|
||||
@@ -79,29 +79,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectApplianceCoordinator]):
|
||||
"""
|
||||
return self.appliance.info.connected and self._attr_available
|
||||
|
||||
async def async_set_option_with_key(
|
||||
self, option_key: OptionKey, value: Any
|
||||
) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id, option_key=option_key, value=value
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
@@ -118,9 +95,40 @@ class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: Any) -> None:
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
"""Set an option for the entity."""
|
||||
await super().async_set_option_with_key(self.bsh_key, value)
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Provides fan entities for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
FanEntity,
|
||||
@@ -11,11 +13,14 @@ from homeassistant.components.fan import (
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -171,7 +176,7 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
|
||||
percentage,
|
||||
)
|
||||
@@ -183,14 +188,41 @@ class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new target fan mode."""
|
||||
await super().async_set_option_with_key(
|
||||
await self._async_set_option(
|
||||
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
|
||||
FAN_SPEED_MODE_OPTIONS[preset_mode],
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s's speed mode option, new state: %s", self.entity_id, self.state
|
||||
"Updated %s's speed mode option, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
|
||||
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=key,
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
|
||||
@@ -119,23 +119,6 @@
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"active_clean": "Active clean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_conditioner": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"started_drying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
),
|
||||
"started_humidifying": make_entity_target_state_trigger(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.HUMIDIFYING
|
||||
"started_humidifying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
|
||||
@@ -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,11 +38,24 @@ 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": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
|
||||
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
HUMIDITY_DOMAIN_SPECS
|
||||
),
|
||||
"changed": HumidityChangedTrigger,
|
||||
"crossed_threshold": HumidityCrossedThresholdTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.11.1"]
|
||||
"requirements": ["librehardwaremonitor-api==1.10.1"]
|
||||
}
|
||||
|
||||
@@ -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,13 +28,24 @@ 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": make_entity_numerical_state_changed_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
|
||||
BRIGHTNESS_DOMAIN_SPECS
|
||||
),
|
||||
"brightness_changed": BrightnessChangedTrigger,
|
||||
"brightness_crossed_threshold": BrightnessCrossedThresholdTrigger,
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
|
||||
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylitterbot"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pylitterbot==2025.1.0"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Move big data objects from common.py into JSON fixtures and oad them when needed.
|
||||
Other fields can be moved to const.py. Consider snapshots and testing data updates
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -218,6 +218,12 @@
|
||||
"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.",
|
||||
|
||||
@@ -6,13 +6,24 @@ from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic
|
||||
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, Robot
|
||||
from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
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
|
||||
|
||||
@@ -66,13 +77,54 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up Litter-Robot switches using config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
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
|
||||
)
|
||||
]
|
||||
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
for robot in coordinator.account.get_robots(LitterRobot4):
|
||||
add_deprecated_entity(
|
||||
robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==13.2.2"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==13.2.2"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiomealie==1.2.2"]
|
||||
"requirements": ["aiomealie==1.2.1"]
|
||||
}
|
||||
|
||||
@@ -7,15 +7,36 @@ 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 Trigger, make_entity_target_state_trigger
|
||||
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}
|
||||
|
||||
_MOTION_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"detected": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_ON),
|
||||
"cleared": make_entity_target_state_trigger(_MOTION_DOMAIN_SPECS, STATE_OFF),
|
||||
"detected": MotionDetectedTrigger,
|
||||
"cleared": MotionClearedTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,8 @@ 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(
|
||||
|
||||
@@ -24,7 +24,7 @@ class StationPriceData:
|
||||
prices: dict[tuple[int, str], float]
|
||||
|
||||
|
||||
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData]):
|
||||
class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData | None]):
|
||||
"""Class to manage fetching NSW fuel station data."""
|
||||
|
||||
config_entry: None
|
||||
@@ -40,14 +40,14 @@ class NSWFuelStationCoordinator(DataUpdateCoordinator[StationPriceData]):
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self) -> StationPriceData:
|
||||
async def _async_update_data(self) -> StationPriceData | None:
|
||||
"""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:
|
||||
def _fetch_station_price_data(client: FuelCheckClient) -> StationPriceData | None:
|
||||
"""Fetch fuel price and station data."""
|
||||
try:
|
||||
raw_price_data = client.get_fuel_prices()
|
||||
|
||||
@@ -65,6 +65,10 @@ 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:
|
||||
@@ -106,6 +110,9 @@ 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))
|
||||
|
||||
@@ -122,13 +129,16 @@ class StationPriceSensor(CoordinatorEntity[NSWFuelStationCoordinator], SensorEnt
|
||||
"""Return the units of measurement."""
|
||||
return f"{CURRENCY_CENT}/{UnitOfVolume.LITERS}"
|
||||
|
||||
def _get_station_name(self) -> str:
|
||||
if (
|
||||
station := self.coordinator.data.stations.get(self._station_id)
|
||||
) is not None:
|
||||
return station.name
|
||||
def _get_station_name(self):
|
||||
default_name = f"station {self._station_id}"
|
||||
if self.coordinator.data is None:
|
||||
return default_name
|
||||
|
||||
return f"station {self._station_id}"
|
||||
station = self.coordinator.data.stations.get(self._station_id)
|
||||
if station is None:
|
||||
return default_name
|
||||
|
||||
return station.name
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str | None:
|
||||
|
||||
@@ -7,15 +7,40 @@ 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 Trigger, make_entity_target_state_trigger
|
||||
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}
|
||||
|
||||
_OCCUPANCY_DOMAIN_SPECS = {
|
||||
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
|
||||
}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"detected": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_ON),
|
||||
"cleared": make_entity_target_state_trigger(_OCCUPANCY_DOMAIN_SPECS, STATE_OFF),
|
||||
"detected": OccupancyDetectedTrigger,
|
||||
"cleared": OccupancyClearedTrigger,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -180,9 +180,6 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
upload_chunk_size=upload_chunk_size,
|
||||
session=async_get_clientsession(self._hass),
|
||||
smart_chunk_size=True,
|
||||
progress_callback=lambda bytes_uploaded: on_progress(
|
||||
bytes_uploaded=bytes_uploaded
|
||||
),
|
||||
)
|
||||
except HashMismatchError as err:
|
||||
raise BackupAgentError(
|
||||
|
||||
@@ -174,9 +174,6 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
upload_chunk_size=upload_chunk_size,
|
||||
session=async_get_clientsession(self._hass),
|
||||
smart_chunk_size=True,
|
||||
progress_callback=lambda bytes_uploaded: on_progress(
|
||||
bytes_uploaded=bytes_uploaded
|
||||
),
|
||||
)
|
||||
except HashMismatchError as err:
|
||||
raise BackupAgentError(
|
||||
|
||||
@@ -54,7 +54,6 @@ from .const import (
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_REASONING_SUMMARY,
|
||||
CONF_RECOMMENDED,
|
||||
CONF_SERVICE_TIER,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_TTS_SPEED,
|
||||
@@ -81,7 +80,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_SERVICE_TIER,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_STT_OPTIONS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
@@ -94,10 +92,8 @@ 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,
|
||||
)
|
||||
|
||||
@@ -447,25 +443,6 @@ 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)
|
||||
):
|
||||
@@ -586,20 +563,6 @@ 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] = {}
|
||||
|
||||
@@ -24,7 +24,6 @@ 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"
|
||||
@@ -43,7 +42,6 @@ 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
|
||||
@@ -121,38 +119,3 @@ 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",
|
||||
]
|
||||
|
||||
@@ -74,7 +74,6 @@ from .const import (
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_REASONING_SUMMARY,
|
||||
CONF_SERVICE_TIER,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_VERBOSITY,
|
||||
@@ -93,7 +92,6 @@ from .const import (
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_REASONING_SUMMARY,
|
||||
RECOMMENDED_SERVICE_TIER,
|
||||
RECOMMENDED_STT_MODEL,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
@@ -501,7 +499,6 @@ 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,
|
||||
)
|
||||
@@ -658,15 +655,6 @@ 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:
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"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%]"
|
||||
},
|
||||
@@ -81,7 +80,6 @@
|
||||
"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%]"
|
||||
},
|
||||
@@ -133,7 +131,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -144,7 +141,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -246,14 +242,6 @@
|
||||
"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%]",
|
||||
|
||||
@@ -18,7 +18,6 @@ TO_REDACT = {
|
||||
CONF_TOTP_SECRET,
|
||||
# Title contains the username/email
|
||||
"title",
|
||||
"utility_account_id",
|
||||
}
|
||||
|
||||
|
||||
@@ -28,46 +27,43 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
"entry": entry.as_dict(),
|
||||
"data": [
|
||||
{
|
||||
"account": {
|
||||
"utility_account_id": account.utility_account_id,
|
||||
"meter_type": account.meter_type.name,
|
||||
"read_resolution": (
|
||||
account.read_resolution.name
|
||||
if account.read_resolution
|
||||
else None
|
||||
),
|
||||
},
|
||||
"forecast": (
|
||||
{
|
||||
"usage_to_date": forecast.usage_to_date,
|
||||
"cost_to_date": forecast.cost_to_date,
|
||||
"forecasted_usage": forecast.forecasted_usage,
|
||||
"forecasted_cost": forecast.forecasted_cost,
|
||||
"typical_usage": forecast.typical_usage,
|
||||
"typical_cost": forecast.typical_cost,
|
||||
"unit_of_measure": forecast.unit_of_measure.name,
|
||||
"start_date": forecast.start_date.isoformat(),
|
||||
"end_date": forecast.end_date.isoformat(),
|
||||
"current_date": forecast.current_date.isoformat(),
|
||||
}
|
||||
if (forecast := data.forecast)
|
||||
return {
|
||||
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"data": {
|
||||
account_id: {
|
||||
"account": {
|
||||
"utility_account_id": account.utility_account_id,
|
||||
"meter_type": account.meter_type.name,
|
||||
"read_resolution": (
|
||||
account.read_resolution.name
|
||||
if account.read_resolution
|
||||
else None
|
||||
),
|
||||
"last_changed": (
|
||||
data.last_changed.isoformat() if data.last_changed else None
|
||||
),
|
||||
"last_updated": (
|
||||
data.last_updated.isoformat() if data.last_updated else None
|
||||
),
|
||||
}
|
||||
for data in coordinator.data.values()
|
||||
for account in (data.account,)
|
||||
],
|
||||
},
|
||||
"forecast": (
|
||||
{
|
||||
"usage_to_date": forecast.usage_to_date,
|
||||
"cost_to_date": forecast.cost_to_date,
|
||||
"forecasted_usage": forecast.forecasted_usage,
|
||||
"forecasted_cost": forecast.forecasted_cost,
|
||||
"typical_usage": forecast.typical_usage,
|
||||
"typical_cost": forecast.typical_cost,
|
||||
"unit_of_measure": forecast.unit_of_measure.name,
|
||||
"start_date": forecast.start_date.isoformat(),
|
||||
"end_date": forecast.end_date.isoformat(),
|
||||
"current_date": forecast.current_date.isoformat(),
|
||||
}
|
||||
if (forecast := data.forecast)
|
||||
else None
|
||||
),
|
||||
"last_changed": (
|
||||
data.last_changed.isoformat() if data.last_changed else None
|
||||
),
|
||||
"last_updated": (
|
||||
data.last_updated.isoformat() if data.last_updated else None
|
||||
),
|
||||
}
|
||||
for account_id, data in coordinator.data.items()
|
||||
for account in (data.account,)
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-pooldose==0.8.6"]
|
||||
"requirements": ["python-pooldose==0.8.5"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from pyportainer import Portainer
|
||||
from pyportainer.exceptions import PortainerError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -139,26 +138,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry)
|
||||
|
||||
hass.config_entries.async_update_entry(entry=entry, version=4)
|
||||
|
||||
if entry.version < 5:
|
||||
client = Portainer(
|
||||
api_url=entry.data[CONF_URL],
|
||||
api_key=entry.data[CONF_API_TOKEN],
|
||||
session=async_create_clientsession(
|
||||
hass=hass, verify_ssl=entry.data[CONF_VERIFY_SSL]
|
||||
),
|
||||
)
|
||||
try:
|
||||
system_status = await client.portainer_system_status()
|
||||
except PortainerError:
|
||||
_LOGGER.exception("Failed to fetch instance ID during migration")
|
||||
return False
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry=entry,
|
||||
unique_id=system_status.instance_id,
|
||||
version=5,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from pyportainer import (
|
||||
PortainerConnectionError,
|
||||
PortainerTimeoutError,
|
||||
)
|
||||
from pyportainer.models.portainer import PortainerSystemStatus
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -33,9 +32,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def _validate_input(
|
||||
hass: HomeAssistant, data: dict[str, Any]
|
||||
) -> PortainerSystemStatus:
|
||||
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
client = Portainer(
|
||||
@@ -44,7 +41,7 @@ async def _validate_input(
|
||||
session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]),
|
||||
)
|
||||
try:
|
||||
system_status = await client.portainer_system_status()
|
||||
await client.get_endpoints()
|
||||
except PortainerAuthenticationError:
|
||||
raise InvalidAuth from None
|
||||
except PortainerConnectionError as err:
|
||||
@@ -53,13 +50,12 @@ async def _validate_input(
|
||||
raise PortainerTimeout from err
|
||||
|
||||
_LOGGER.debug("Connected to Portainer API: %s", data[CONF_URL])
|
||||
return system_status
|
||||
|
||||
|
||||
class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Portainer."""
|
||||
|
||||
VERSION = 5
|
||||
VERSION = 4
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -67,8 +63,9 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
|
||||
try:
|
||||
system_status = await _validate_input(self.hass, user_input)
|
||||
await _validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
@@ -79,7 +76,7 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(system_status.instance_id)
|
||||
await self.async_set_unique_id(user_input[CONF_API_TOKEN])
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_URL], data=user_input
|
||||
@@ -145,7 +142,7 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
system_status = await _validate_input(
|
||||
await _validate_input(
|
||||
self.hass,
|
||||
data={
|
||||
**reconf_entry.data,
|
||||
@@ -162,8 +159,12 @@ class PortainerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(system_status.instance_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
# Logic that can be reverted back once the new unique ID is in
|
||||
existing_entry = await self.async_set_unique_id(
|
||||
user_input[CONF_API_TOKEN]
|
||||
)
|
||||
if existing_entry and existing_entry.entry_id != reconf_entry.entry_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
return self.async_update_reload_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The Portainer instance ID does not match the previously configured instance. This can occur if the device was reset or reconfigured outside of Home Assistant."
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""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
|
||||
@@ -9,16 +12,26 @@ 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: EntityDescription,
|
||||
description: PranaEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Prana entity."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
@@ -22,7 +21,7 @@ from homeassistant.util.percentage import (
|
||||
from homeassistant.util.scaling import int_states_in_range
|
||||
|
||||
from . import PranaConfigEntry
|
||||
from .entity import PranaBaseEntity, PranaCoordinator
|
||||
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -41,15 +40,14 @@ class PranaFanType(StrEnum):
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaFanEntityDescription(FanEntityDescription):
|
||||
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
|
||||
"""Description of a Prana fan entity."""
|
||||
|
||||
key: PranaFanType
|
||||
value_fn: Callable[[PranaCoordinator], FanState]
|
||||
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaFanEntityDescription, ...] = (
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
PranaFanEntityDescription(
|
||||
key=PranaFanType.SUPPLY,
|
||||
translation_key="supply",
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""Switch platform for Prana integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from aioesphomeapi import dataclass
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
StrEnum,
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PranaConfigEntry, PranaCoordinator
|
||||
from .entity import PranaBaseEntity
|
||||
from .entity import PranaBaseEntity, PranaEntityDescription
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -28,14 +32,13 @@ class PranaSwitchType(StrEnum):
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PranaSwitchEntityDescription(SwitchEntityDescription):
|
||||
class PranaSwitchEntityDescription(SwitchEntityDescription, PranaEntityDescription):
|
||||
"""Description of a Prana switch entity."""
|
||||
|
||||
key: PranaSwitchType
|
||||
value_fn: Callable[[PranaCoordinator], bool]
|
||||
|
||||
|
||||
ENTITIES: tuple[PranaSwitchEntityDescription, ...] = (
|
||||
ENTITIES: tuple[PranaEntityDescription, ...] = (
|
||||
PranaSwitchEntityDescription(
|
||||
key=PranaSwitchType.BOUND,
|
||||
translation_key="bound",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==13.2.2"]
|
||||
"requirements": ["ical==13.2.0"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmlight==0.3.0"],
|
||||
"requirements": ["pysmlight==0.2.16"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_slzb-06._tcp.local."
|
||||
|
||||
@@ -33,7 +33,6 @@ 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,
|
||||
@@ -46,23 +45,45 @@ _LOGGER = logging.getLogger(__name__)
|
||||
TIMEOUT = 5
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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})
|
||||
|
||||
SHORT_EDIT_SCHEMA = vol.Schema(
|
||||
{
|
||||
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)
|
||||
|
||||
|
||||
class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -72,9 +93,8 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize an instance of the squeezebox config flow."""
|
||||
self.discovery_task: asyncio.Task | None = None
|
||||
self.discovered_servers: list[dict[str, Any]] = []
|
||||
self.chosen_server: dict[str, Any] = {}
|
||||
self.data_schema = _base_schema()
|
||||
self.discovery_info: dict[str, Any] | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@@ -82,43 +102,34 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def _discover(self) -> None:
|
||||
async def _discover(self, uuid: str | None = None) -> None:
|
||||
"""Discover an unconfigured LMS server."""
|
||||
# Reset discovery state to avoid stale or duplicate servers across runs
|
||||
self.discovered_servers = []
|
||||
self.chosen_server = {}
|
||||
_discovery_task: asyncio.Task | None = None
|
||||
self.discovery_info = None
|
||||
discovery_event = asyncio.Event()
|
||||
|
||||
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
|
||||
_discovery_info = {
|
||||
self.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()
|
||||
|
||||
_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(
|
||||
discovery_task = self.hass.async_create_task(
|
||||
async_discover(_discovery_callback)
|
||||
)
|
||||
|
||||
await asyncio.sleep(TIMEOUT)
|
||||
await discovery_event.wait()
|
||||
discovery_task.cancel() # stop searching as soon as we find server
|
||||
|
||||
_LOGGER.debug("Discovered Servers %s", self.discovered_servers)
|
||||
_discovery_task.cancel()
|
||||
# update with suggested values from discovery
|
||||
self.data_schema = _base_schema(self.discovery_info)
|
||||
|
||||
async def _validate_input(self, data: dict[str, Any]) -> str | None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -131,7 +142,7 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data[CONF_PORT],
|
||||
data.get(CONF_USERNAME),
|
||||
data.get(CONF_PASSWORD),
|
||||
https=data.get(CONF_HTTPS, False),
|
||||
https=data[CONF_HTTPS],
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -153,78 +164,35 @@ 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()
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="user", menu_options=["start_discovery", "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"
|
||||
|
||||
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,
|
||||
# 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_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)
|
||||
@@ -235,95 +203,39 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
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,
|
||||
step_id="edit", data_schema=self.data_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()
|
||||
|
||||
self.context.update(
|
||||
{"title_placeholders": {"host": _discovery_info[CONF_HOST]}}
|
||||
)
|
||||
self.chosen_server = _discovery_info
|
||||
return await self.async_step_edit_integration_discovered()
|
||||
# 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()
|
||||
|
||||
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)
|
||||
|
||||
@@ -56,4 +56,3 @@ 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"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"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": {
|
||||
@@ -13,27 +12,7 @@
|
||||
"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%]",
|
||||
@@ -43,47 +22,21 @@
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address or hostname of the LMS.",
|
||||
"host": "[%key:component::squeezebox::config::step::user::data_description::host%]",
|
||||
"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)."
|
||||
}
|
||||
},
|
||||
"edit_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": "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"
|
||||
"title": "Edit connection information"
|
||||
},
|
||||
"user": {
|
||||
"menu_options": {
|
||||
"edit": "Enter configuration manually",
|
||||
"start_discovery": "Discover new LMS"
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"title": "LMS configuration"
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Lyrion Music Server."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -307,11 +260,11 @@
|
||||
"description": "Calls a custom Squeezebox JSONRPC API.",
|
||||
"fields": {
|
||||
"command": {
|
||||
"description": "Command to pass to LMS (p0 in the CLI documentation).",
|
||||
"description": "Command to pass to Lyrion Music Server (p0 in the CLI documentation).",
|
||||
"name": "Command"
|
||||
},
|
||||
"parameters": {
|
||||
"description": "Array of additional parameters to pass to LMS (p1, ..., pN in the CLI documentation).",
|
||||
"description": "Array of additional parameters to pass to Lyrion Music Server (p1, ..., pN in the CLI documentation).",
|
||||
"name": "Parameters"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -245,17 +245,15 @@ 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 == "Battery Circulator Fan":
|
||||
if isinstance(device, Device) and device.device_type in [
|
||||
"Battery Circulator Fan",
|
||||
"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",
|
||||
|
||||
@@ -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,6 +110,10 @@ 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),
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["telegram"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["python-telegram-bot[socks]==22.6"]
|
||||
"requirements": ["python-telegram-bot[socks]==22.1"]
|
||||
}
|
||||
|
||||
@@ -18,12 +18,6 @@ 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)
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import TRMNLConfigEntry, TRMNLCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME]
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool:
|
||||
|
||||
@@ -9,12 +9,7 @@ from trmnl import TRMNLClient
|
||||
from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -22,8 +17,6 @@ from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
|
||||
|
||||
TRMNL_ACCOUNT_URL = "https://trmnl.com/account"
|
||||
|
||||
|
||||
class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""TRMNL config flow."""
|
||||
@@ -31,7 +24,7 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user, reauth, or reconfigure."""
|
||||
"""Handle a flow initialized by the user or reauth."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input:
|
||||
session = async_get_clientsession(self.hass)
|
||||
@@ -53,12 +46,6 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user.name,
|
||||
@@ -68,7 +55,6 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={"account_url": TRMNL_ACCOUNT_URL},
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
@@ -76,9 +62,3 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
return await self.async_step_user()
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from trmnl.exceptions import TRMNLError
|
||||
from trmnl.models import Device
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -42,24 +37,3 @@ class TRMNLEntity(CoordinatorEntity[TRMNLCoordinator]):
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available."""
|
||||
return super().available and self._device_id in self.coordinator.data
|
||||
|
||||
|
||||
def exception_handler[_EntityT: TRMNLEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate TRMNL calls to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches TRMNL errors.
|
||||
"""
|
||||
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except TRMNLError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_error",
|
||||
translation_placeholders={"error": str(error)},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"wifi_strength": {
|
||||
"default": "mdi:wifi-strength-off-outline",
|
||||
"range": {
|
||||
"0": "mdi:wifi-strength-1",
|
||||
"25": "mdi:wifi-strength-2",
|
||||
"50": "mdi:wifi-strength-3",
|
||||
"75": "mdi:wifi-strength-4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"sleep_mode": {
|
||||
"default": "mdi:sleep-off",
|
||||
@@ -18,14 +7,6 @@
|
||||
"on": "mdi:sleep"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"sleep_end_time": {
|
||||
"default": "mdi:sleep-off"
|
||||
},
|
||||
"sleep_start_time": {
|
||||
"default": "mdi:sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["trmnl==0.1.1"]
|
||||
"requirements": ["trmnl==0.1.0"]
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: There are no configuration parameters
|
||||
docs-installation-parameters: done
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
@@ -48,21 +48,21 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Can't be discovered
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
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
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: There are no repairable issues
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -45,16 +44,6 @@ SENSOR_DESCRIPTIONS: tuple[TRMNLSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.percent_charged,
|
||||
),
|
||||
TRMNLSensorEntityDescription(
|
||||
key="battery_voltage",
|
||||
translation_key="battery_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda device: device.battery_voltage,
|
||||
),
|
||||
TRMNLSensorEntityDescription(
|
||||
key="rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
@@ -64,15 +53,6 @@ SENSOR_DESCRIPTIONS: tuple[TRMNLSensorEntityDescription, ...] = (
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda device: device.rssi,
|
||||
),
|
||||
TRMNLSensorEntityDescription(
|
||||
key="wifi_strength",
|
||||
translation_key="wifi_strength",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda device: device.wifi_strength,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,21 +63,11 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up TRMNL sensor entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_device_ids: set[int] = set()
|
||||
|
||||
def _async_entity_listener() -> None:
|
||||
new_ids = set(coordinator.data) - known_device_ids
|
||||
if new_ids:
|
||||
async_add_entities(
|
||||
TRMNLSensor(coordinator, device_id, description)
|
||||
for device_id in new_ids
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
known_device_ids.update(new_ids)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
|
||||
_async_entity_listener()
|
||||
async_add_entities(
|
||||
TRMNLSensor(coordinator, device_id, description)
|
||||
for device_id in coordinator.data
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class TRMNLSensor(TRMNLEntity, SensorEntity):
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The API key belongs to a different account. Please use the API key for the original account."
|
||||
},
|
||||
"error": {
|
||||
@@ -11,9 +10,6 @@
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "[%key:common::config_flow::initiate_flow::account%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
@@ -21,38 +17,18 @@
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key for your TRMNL account."
|
||||
},
|
||||
"description": "You can find your API key on [your TRMNL account page]({account_url})."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"wifi_strength": {
|
||||
"name": "Wi-Fi strength"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"sleep_mode": {
|
||||
"name": "Sleep mode"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"sleep_end_time": {
|
||||
"name": "Sleep end time"
|
||||
},
|
||||
"sleep_start_time": {
|
||||
"name": "Sleep start time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"action_error": {
|
||||
"message": "An error occurred while communicating with TRMNL: {error}"
|
||||
},
|
||||
"authentication_error": {
|
||||
"message": "Authentication failed. Please check your API key."
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TRMNLConfigEntry
|
||||
from .coordinator import TRMNLCoordinator
|
||||
from .entity import TRMNLEntity, exception_handler
|
||||
from .entity import TRMNLEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -48,21 +48,11 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up TRMNL switch entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_device_ids: set[int] = set()
|
||||
|
||||
def _async_entity_listener() -> None:
|
||||
new_ids = set(coordinator.data) - known_device_ids
|
||||
if new_ids:
|
||||
async_add_entities(
|
||||
TRMNLSwitchEntity(coordinator, device_id, description)
|
||||
for device_id in new_ids
|
||||
for description in SWITCH_DESCRIPTIONS
|
||||
)
|
||||
known_device_ids.update(new_ids)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
|
||||
_async_entity_listener()
|
||||
async_add_entities(
|
||||
TRMNLSwitchEntity(coordinator, device_id, description)
|
||||
for device_id in coordinator.data
|
||||
for description in SWITCH_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity):
|
||||
@@ -86,7 +76,6 @@ class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity):
|
||||
"""Return if sleep mode is enabled."""
|
||||
return self.entity_description.value_fn(self._device)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Enable sleep mode."""
|
||||
await self.entity_description.set_value_fn(
|
||||
@@ -94,7 +83,6 @@ class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity):
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Disable sleep mode."""
|
||||
await self.entity_description.set_value_fn(
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Support for TRMNL time entities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
from typing import Any
|
||||
|
||||
from trmnl.models import Device
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TRMNLConfigEntry
|
||||
from .coordinator import TRMNLCoordinator
|
||||
from .entity import TRMNLEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def _minutes_to_time(minutes: int) -> time:
|
||||
"""Convert minutes since midnight to a time object."""
|
||||
return time(hour=minutes // 60, minute=minutes % 60)
|
||||
|
||||
|
||||
def _time_to_minutes(value: time) -> int:
|
||||
"""Convert a time object to minutes since midnight."""
|
||||
return value.hour * 60 + value.minute
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TRMNLTimeEntityDescription(TimeEntityDescription):
|
||||
"""Describes a TRMNL time entity."""
|
||||
|
||||
value_fn: Callable[[Device], time]
|
||||
set_value_fn: Callable[[TRMNLCoordinator, int, time], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
TIME_DESCRIPTIONS: tuple[TRMNLTimeEntityDescription, ...] = (
|
||||
TRMNLTimeEntityDescription(
|
||||
key="sleep_start_time",
|
||||
translation_key="sleep_start_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda device: _minutes_to_time(device.sleep_start_time),
|
||||
set_value_fn=lambda coordinator, device_id, value: (
|
||||
coordinator.client.update_device(
|
||||
device_id, sleep_start_time=_time_to_minutes(value)
|
||||
)
|
||||
),
|
||||
),
|
||||
TRMNLTimeEntityDescription(
|
||||
key="sleep_end_time",
|
||||
translation_key="sleep_end_time",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
value_fn=lambda device: _minutes_to_time(device.sleep_end_time),
|
||||
set_value_fn=lambda coordinator, device_id, value: (
|
||||
coordinator.client.update_device(
|
||||
device_id, sleep_end_time=_time_to_minutes(value)
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TRMNLConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up TRMNL time entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
known_device_ids: set[int] = set()
|
||||
|
||||
def _async_entity_listener() -> None:
|
||||
new_ids = set(coordinator.data) - known_device_ids
|
||||
if new_ids:
|
||||
async_add_entities(
|
||||
TRMNLTimeEntity(coordinator, device_id, description)
|
||||
for device_id in new_ids
|
||||
for description in TIME_DESCRIPTIONS
|
||||
)
|
||||
known_device_ids.update(new_ids)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_entity_listener))
|
||||
_async_entity_listener()
|
||||
|
||||
|
||||
class TRMNLTimeEntity(TRMNLEntity, TimeEntity):
|
||||
"""Defines a TRMNL time entity."""
|
||||
|
||||
entity_description: TRMNLTimeEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TRMNLCoordinator,
|
||||
device_id: int,
|
||||
description: TRMNLTimeEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize TRMNL time entity."""
|
||||
super().__init__(coordinator, device_id)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> time:
|
||||
"""Return the current time value."""
|
||||
return self.entity_description.value_fn(self._device)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Set the time value."""
|
||||
await self.entity_description.set_value_fn(
|
||||
self.coordinator, self._device_id, value
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tuya_device_handlers.device_wrapper.alarm_control_panel import (
|
||||
AlarmActionWrapper,
|
||||
AlarmChangedByWrapper,
|
||||
AlarmStateWrapper,
|
||||
)
|
||||
from base64 import b64decode
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.helpers.homeassistant import (
|
||||
TuyaAlarmControlPanelAction,
|
||||
TuyaAlarmControlPanelState,
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeRawWrapper,
|
||||
)
|
||||
from tuya_device_handlers.type_information import EnumTypeInformation
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
@@ -38,18 +36,84 @@ ALARM: dict[DeviceCategory, tuple[AlarmControlPanelEntityDescription, ...]] = {
|
||||
)
|
||||
}
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -72,13 +136,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
|
||||
),
|
||||
)
|
||||
@@ -110,9 +174,9 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
device_manager: Manager,
|
||||
description: AlarmControlPanelEntityDescription,
|
||||
*,
|
||||
action_wrapper: DeviceWrapper[TuyaAlarmControlPanelAction],
|
||||
action_wrapper: DeviceWrapper[str],
|
||||
changed_by_wrapper: DeviceWrapper[str] | None,
|
||||
state_wrapper: DeviceWrapper[TuyaAlarmControlPanelState],
|
||||
state_wrapper: DeviceWrapper[AlarmControlPanelState],
|
||||
) -> None:
|
||||
"""Init Tuya Alarm."""
|
||||
super().__init__(device, device_manager)
|
||||
@@ -123,18 +187,17 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
self._state_wrapper = state_wrapper
|
||||
|
||||
# Determine supported modes
|
||||
if TuyaAlarmControlPanelAction.ARM_HOME in action_wrapper.options:
|
||||
if "arm_home" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if TuyaAlarmControlPanelAction.ARM_AWAY in action_wrapper.options:
|
||||
if "arm_away" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if TuyaAlarmControlPanelAction.TRIGGER in action_wrapper.options:
|
||||
if "trigger" in action_wrapper.options:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Return the state of the device."""
|
||||
tuya_value = self._read_wrapper(self._state_wrapper)
|
||||
return _TUYA_TO_HA_STATE_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
return self._read_wrapper(self._state_wrapper)
|
||||
|
||||
@property
|
||||
def changed_by(self) -> str | None:
|
||||
@@ -143,24 +206,16 @@ 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, TuyaAlarmControlPanelAction.DISARM
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "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, TuyaAlarmControlPanelAction.ARM_HOME
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "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, TuyaAlarmControlPanelAction.ARM_AWAY
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "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, TuyaAlarmControlPanelAction.TRIGGER
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "trigger")
|
||||
|
||||
@@ -2,25 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
from typing import Any, Self
|
||||
|
||||
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.device_wrapper.extended import DPCodeRoundedIntegerWrapper
|
||||
from tuya_device_handlers.helpers.homeassistant import (
|
||||
TuyaClimateHVACMode,
|
||||
TuyaClimateSwingMode,
|
||||
)
|
||||
from tuya_device_handlers.type_information import EnumTypeInformation
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -49,26 +41,173 @@ from .const import (
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
|
||||
_TUYA_TO_HA_HVACMODE_MAPPINGS: dict[TuyaClimateHVACMode | None, HVACMode | None] = {
|
||||
None: None,
|
||||
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,
|
||||
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,
|
||||
}
|
||||
_HA_TO_TUYA_HVACMODE_MAPPINGS = {v: k for k, v in _TUYA_TO_HA_HVACMODE_MAPPINGS.items()}
|
||||
|
||||
_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()}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -219,7 +358,7 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
CLIMATE_DESCRIPTIONS[device.category],
|
||||
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_CURRENT
|
||||
),
|
||||
current_temperature_wrapper=temperature_wrappers[0],
|
||||
@@ -228,18 +367,18 @@ async def async_setup_entry(
|
||||
(DPCode.FAN_SPEED_ENUM, DPCode.LEVEL, DPCode.WINDSPEED),
|
||||
prefer_function=True,
|
||||
),
|
||||
hvac_mode_wrapper=DefaultHVACModeWrapper.find_dpcode(
|
||||
hvac_mode_wrapper=_HvacModeWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
preset_wrapper=DefaultPresetModeWrapper.find_dpcode(
|
||||
preset_wrapper=_PresetWrapper.find_dpcode(
|
||||
device, DPCode.MODE, prefer_function=True
|
||||
),
|
||||
set_temperature_wrapper=temperature_wrappers[1],
|
||||
swing_wrapper=SwingModeCompositeWrapper.find_dpcode(device),
|
||||
swing_wrapper=_SwingModeWrapper.find_dpcode(device),
|
||||
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
|
||||
device, DPCode.SWITCH, prefer_function=True
|
||||
),
|
||||
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, DPCode.HUMIDITY_SET, prefer_function=True
|
||||
),
|
||||
temperature_unit=temperature_wrappers[2],
|
||||
@@ -269,10 +408,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[TuyaClimateHVACMode] | None,
|
||||
hvac_mode_wrapper: DeviceWrapper[HVACMode] | None,
|
||||
preset_wrapper: DeviceWrapper[str] | None,
|
||||
set_temperature_wrapper: DeviceWrapper[float] | None,
|
||||
swing_wrapper: DeviceWrapper[TuyaClimateSwingMode] | None,
|
||||
swing_wrapper: DeviceWrapper[str] | None,
|
||||
switch_wrapper: DeviceWrapper[bool] | None,
|
||||
target_humidity_wrapper: DeviceWrapper[int] | None,
|
||||
temperature_unit: UnitOfTemperature,
|
||||
@@ -336,13 +475,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
# Determine swing modes
|
||||
if swing_wrapper:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
|
||||
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))
|
||||
]
|
||||
self._attr_swing_modes = swing_wrapper.options
|
||||
|
||||
if switch_wrapper:
|
||||
self._attr_supported_features |= (
|
||||
@@ -358,13 +491,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
self.device, hvac_mode != HVACMode.OFF
|
||||
)
|
||||
)
|
||||
if (
|
||||
self._hvac_mode_wrapper
|
||||
and hvac_mode in self._hvac_mode_wrapper.options
|
||||
and (tuya_mode := _HA_TO_TUYA_HVACMODE_MAPPINGS.get(hvac_mode))
|
||||
):
|
||||
if self._hvac_mode_wrapper and hvac_mode in self._hvac_mode_wrapper.options:
|
||||
commands.extend(
|
||||
self._hvac_mode_wrapper.get_update_commands(self.device, tuya_mode)
|
||||
self._hvac_mode_wrapper.get_update_commands(self.device, hvac_mode)
|
||||
)
|
||||
await self._async_send_commands(commands)
|
||||
|
||||
@@ -382,8 +511,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new target swing operation."""
|
||||
if tuya_mode := _HA_TO_TUYA_SWING_MAPPINGS.get(swing_mode):
|
||||
await self._async_send_wrapper_updates(self._swing_wrapper, tuya_mode)
|
||||
await self._async_send_wrapper_updates(self._swing_wrapper, swing_mode)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
@@ -426,9 +554,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
return None
|
||||
|
||||
# If we do have a mode wrapper, check if the mode maps to an HVAC mode.
|
||||
return _TUYA_TO_HA_HVACMODE_MAPPINGS.get(
|
||||
self._read_wrapper(self._hvac_mode_wrapper)
|
||||
)
|
||||
return self._read_wrapper(self._hvac_mode_wrapper)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
@@ -443,8 +569,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return swing mode."""
|
||||
tuya_value = self._read_wrapper(self._swing_wrapper)
|
||||
return _TUYA_TO_HA_SWING_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
return self._read_wrapper(self._swing_wrapper)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the device on, retaining current HVAC (if supported)."""
|
||||
|
||||
@@ -6,19 +6,16 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
from tuya_device_handlers.device_wrapper.cover import (
|
||||
ControlBackModePercentageMappingWrapper,
|
||||
CoverClosedEnumWrapper,
|
||||
CoverInstructionBooleanWrapper,
|
||||
CoverInstructionEnumWrapper,
|
||||
CoverInstructionSpecialEnumWrapper,
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.device_wrapper.extended import (
|
||||
DPCodeInvertedBooleanWrapper,
|
||||
DPCodeInvertedPercentageWrapper,
|
||||
DPCodePercentageWrapper,
|
||||
from tuya_device_handlers.type_information import (
|
||||
EnumTypeInformation,
|
||||
IntegerTypeInformation,
|
||||
)
|
||||
from tuya_device_handlers.helpers.homeassistant import TuyaCoverAction
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
@@ -38,17 +35,123 @@ 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[
|
||||
DPCodeInvertedBooleanWrapper | CoverClosedEnumWrapper
|
||||
] = CoverClosedEnumWrapper
|
||||
current_state_wrapper: type[_IsClosedInvertedWrapper | _IsClosedEnumWrapper] = (
|
||||
_IsClosedEnumWrapper
|
||||
)
|
||||
current_position: DPCode | tuple[DPCode, ...] | None = None
|
||||
instruction_wrapper: type[CoverInstructionEnumWrapper] = CoverInstructionEnumWrapper
|
||||
position_wrapper: type[DPCodePercentageWrapper] = DPCodeInvertedPercentageWrapper
|
||||
instruction_wrapper: type[_InstructionEnumWrapper] = _InstructionEnumWrapper
|
||||
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
|
||||
_InvertedPercentageMappingWrapper
|
||||
)
|
||||
set_position: DPCode | None = None
|
||||
|
||||
|
||||
@@ -59,7 +162,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "1"},
|
||||
current_state=DPCode.DOORCONTACT_STATE,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -67,7 +170,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "2"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_2,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
TuyaCoverEntityDescription(
|
||||
@@ -75,7 +178,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
translation_key="indexed_door",
|
||||
translation_placeholders={"index": "3"},
|
||||
current_state=DPCode.DOORCONTACT_STATE_3,
|
||||
current_state_wrapper=DPCodeInvertedBooleanWrapper,
|
||||
current_state_wrapper=_IsClosedInvertedWrapper,
|
||||
device_class=CoverDeviceClass.GARAGE,
|
||||
),
|
||||
),
|
||||
@@ -110,7 +213,7 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
|
||||
current_position=DPCode.POSITION,
|
||||
set_position=DPCode.POSITION,
|
||||
device_class=CoverDeviceClass.CURTAIN,
|
||||
instruction_wrapper=CoverInstructionSpecialEnumWrapper,
|
||||
instruction_wrapper=_SpecialInstructionEnumWrapper,
|
||||
),
|
||||
# switch_1 is an undocumented code that behaves identically to control
|
||||
# It is used by the Kogan Smart Blinds Driver
|
||||
@@ -127,7 +230,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,
|
||||
),
|
||||
@@ -136,7 +239,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,
|
||||
),
|
||||
@@ -163,7 +266,7 @@ def _get_instruction_wrapper(
|
||||
return enum_wrapper
|
||||
|
||||
# Fallback to a boolean wrapper if available
|
||||
return CoverInstructionBooleanWrapper.find_dpcode(
|
||||
return _InstructionBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
|
||||
@@ -235,7 +338,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
*,
|
||||
current_position: DeviceWrapper[int] | None,
|
||||
current_state_wrapper: DeviceWrapper[bool] | None,
|
||||
instruction_wrapper: DeviceWrapper[TuyaCoverAction] | None,
|
||||
instruction_wrapper: DeviceWrapper[str] | None,
|
||||
set_position: DeviceWrapper[int] | None,
|
||||
tilt_position: DeviceWrapper[int] | None,
|
||||
) -> None:
|
||||
@@ -252,11 +355,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._tilt_position = tilt_position
|
||||
|
||||
if instruction_wrapper:
|
||||
if TuyaCoverAction.OPEN in instruction_wrapper.options:
|
||||
if "open" in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.OPEN
|
||||
if TuyaCoverAction.CLOSE in instruction_wrapper.options:
|
||||
if "close" in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.CLOSE
|
||||
if TuyaCoverAction.STOP in instruction_wrapper.options:
|
||||
if "stop" in instruction_wrapper.options:
|
||||
self._attr_supported_features |= CoverEntityFeature.STOP
|
||||
|
||||
if set_position:
|
||||
@@ -296,11 +399,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and TuyaCoverAction.OPEN in self._instruction_wrapper.options
|
||||
and (options := self._instruction_wrapper.options)
|
||||
and "open" in options
|
||||
):
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.OPEN
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "open")
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close cover."""
|
||||
@@ -312,11 +414,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and TuyaCoverAction.CLOSE in self._instruction_wrapper.options
|
||||
and (options := self._instruction_wrapper.options)
|
||||
and "close" in options
|
||||
):
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.CLOSE
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "close")
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
@@ -326,13 +427,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
if (
|
||||
self._instruction_wrapper
|
||||
and TuyaCoverAction.STOP in self._instruction_wrapper.options
|
||||
):
|
||||
await self._async_send_wrapper_updates(
|
||||
self._instruction_wrapper, TuyaCoverAction.STOP
|
||||
)
|
||||
if self._instruction_wrapper and "stop" in self._instruction_wrapper.options:
|
||||
await self._async_send_wrapper_updates(self._instruction_wrapper, "stop")
|
||||
|
||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover tilt to a specific position."""
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
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 DPCodeTypeInformationWrapper
|
||||
from tuya_device_handlers.device_wrapper.event import (
|
||||
Base64Utf8RawEventWrapper,
|
||||
Base64Utf8StringEventWrapper,
|
||||
SimpleEventEnumWrapper,
|
||||
from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeRawWrapper,
|
||||
DPCodeStringWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -28,11 +29,58 @@ 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] = SimpleEventEnumWrapper
|
||||
wrapper_class: type[DPCodeTypeInformationWrapper] = _EventEnumWrapper
|
||||
|
||||
|
||||
# All descriptions can be found here. Mostly the Enum data types in the
|
||||
@@ -44,13 +92,13 @@ EVENTS: dict[DeviceCategory, tuple[TuyaEventEntityDescription, ...]] = {
|
||||
key=DPCode.ALARM_MESSAGE,
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
translation_key="doorbell_message",
|
||||
wrapper_class=Base64Utf8StringEventWrapper,
|
||||
wrapper_class=_AlarmMessageWrapper,
|
||||
),
|
||||
TuyaEventEntityDescription(
|
||||
key=DPCode.DOORBELL_PIC,
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
translation_key="doorbell_picture",
|
||||
wrapper_class=Base64Utf8RawEventWrapper,
|
||||
wrapper_class=_DoorbellPicWrapper,
|
||||
),
|
||||
),
|
||||
DeviceCategory.WXKG: (
|
||||
|
||||
@@ -8,13 +8,10 @@ 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.fan import (
|
||||
FanDirectionEnumWrapper,
|
||||
FanSpeedEnumWrapper,
|
||||
FanSpeedIntegerWrapper,
|
||||
)
|
||||
from tuya_device_handlers.helpers.homeassistant import TuyaFanDirection
|
||||
from tuya_device_handlers.type_information import IntegerTypeInformation
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
@@ -26,6 +23,10 @@ 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
|
||||
@@ -52,13 +53,18 @@ TUYA_SUPPORT_TYPE: set[DeviceCategory] = {
|
||||
DeviceCategory.KS,
|
||||
}
|
||||
|
||||
_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()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _has_a_valid_dpcode(device: CustomerDevice) -> bool:
|
||||
@@ -74,15 +80,50 @@ 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,
|
||||
) -> DeviceWrapper[int] | None:
|
||||
) -> _FanSpeedEnumWrapper | _FanSpeedIntegerWrapper | 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(
|
||||
@@ -104,7 +145,7 @@ async def async_setup_entry(
|
||||
TuyaFanEntity(
|
||||
device,
|
||||
manager,
|
||||
direction_wrapper=FanDirectionEnumWrapper.find_dpcode(
|
||||
direction_wrapper=_DirectionEnumWrapper.find_dpcode(
|
||||
device, _DIRECTION_DPCODES, prefer_function=True
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
@@ -138,7 +179,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
direction_wrapper: DeviceWrapper[TuyaFanDirection] | None,
|
||||
direction_wrapper: DeviceWrapper[str] | None,
|
||||
mode_wrapper: DeviceWrapper[str] | None,
|
||||
oscillate_wrapper: DeviceWrapper[bool] | None,
|
||||
speed_wrapper: DeviceWrapper[int] | None,
|
||||
@@ -179,8 +220,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
|
||||
async def async_set_direction(self, direction: str) -> None:
|
||||
"""Set the direction of the fan."""
|
||||
if tuya_value := _HA_TO_TUYA_DIRECTION_MAPPINGS.get(direction):
|
||||
await self._async_send_wrapper_updates(self._direction_wrapper, tuya_value)
|
||||
await self._async_send_wrapper_updates(self._direction_wrapper, direction)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
@@ -225,8 +265,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity):
|
||||
@property
|
||||
def current_direction(self) -> str | None:
|
||||
"""Return the current direction of the fan."""
|
||||
tuya_value = self._read_wrapper(self._direction_wrapper)
|
||||
return _TUYA_TO_HA_DIRECTION_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
return self._read_wrapper(self._direction_wrapper)
|
||||
|
||||
@property
|
||||
def oscillating(self) -> bool | None:
|
||||
|
||||
@@ -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,6 +29,16 @@ 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."""
|
||||
@@ -94,7 +104,7 @@ async def async_setup_entry(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
current_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, description.current_humidity
|
||||
),
|
||||
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
@@ -105,7 +115,7 @@ async def async_setup_entry(
|
||||
description.dpcode or description.key,
|
||||
prefer_function=True,
|
||||
),
|
||||
target_humidity_wrapper=DPCodeRoundedIntegerWrapper.find_dpcode(
|
||||
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
|
||||
device, description.humidity, prefer_function=True
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
@@ -11,15 +12,9 @@ from tuya_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
)
|
||||
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.type_information import IntegerTypeInformation
|
||||
from tuya_device_handlers.utils import RemapHelper
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -38,6 +33,7 @@ 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
|
||||
@@ -45,6 +41,169 @@ 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."""
|
||||
|
||||
@@ -392,9 +551,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:
|
||||
@@ -419,10 +578,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:
|
||||
@@ -484,7 +643,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,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.13",
|
||||
"tuya-device-handlers==0.0.12",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Self
|
||||
|
||||
from tuya_device_handlers.device_wrapper.base import DeviceWrapper
|
||||
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_device_handlers.device_wrapper.common import (
|
||||
DPCodeBooleanWrapper,
|
||||
DPCodeEnumWrapper,
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -29,14 +24,138 @@ from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -58,8 +177,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
|
||||
),
|
||||
@@ -84,8 +203,8 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
*,
|
||||
action_wrapper: DeviceWrapper[TuyaVacuumAction] | None,
|
||||
activity_wrapper: DeviceWrapper[TuyaVacuumActivity] | None,
|
||||
action_wrapper: DeviceWrapper[str] | None,
|
||||
activity_wrapper: DeviceWrapper[VacuumActivity] | None,
|
||||
fan_speed_wrapper: DeviceWrapper[str] | None,
|
||||
) -> None:
|
||||
"""Init Tuya vacuum."""
|
||||
@@ -98,15 +217,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
self._attr_supported_features = VacuumEntityFeature.SEND_COMMAND
|
||||
|
||||
if action_wrapper:
|
||||
if TuyaVacuumAction.PAUSE in action_wrapper.options:
|
||||
if "pause" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.PAUSE
|
||||
if TuyaVacuumAction.RETURN_TO_BASE in action_wrapper.options:
|
||||
if "return_to_base" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
|
||||
if TuyaVacuumAction.LOCATE in action_wrapper.options:
|
||||
if "locate" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.LOCATE
|
||||
if TuyaVacuumAction.START in action_wrapper.options:
|
||||
if "start" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.START
|
||||
if TuyaVacuumAction.STOP in action_wrapper.options:
|
||||
if "stop" in action_wrapper.options:
|
||||
self._attr_supported_features |= VacuumEntityFeature.STOP
|
||||
|
||||
if activity_wrapper:
|
||||
@@ -124,38 +243,27 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
|
||||
@property
|
||||
def activity(self) -> VacuumActivity | None:
|
||||
"""Return Tuya vacuum device state."""
|
||||
tuya_value = self._read_wrapper(self._activity_wrapper)
|
||||
return _TUYA_TO_HA_ACTIVITY_MAPPINGS.get(tuya_value) if tuya_value else None
|
||||
return self._read_wrapper(self._activity_wrapper)
|
||||
|
||||
async def async_start(self, **kwargs: Any) -> None:
|
||||
"""Start the device."""
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.START
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "start")
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Stop the device."""
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.STOP
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "stop")
|
||||
|
||||
async def async_pause(self, **kwargs: Any) -> None:
|
||||
"""Pause the device."""
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.PAUSE
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "pause")
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Return device to dock."""
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.RETURN_TO_BASE
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "return_to_base")
|
||||
|
||||
async def async_locate(self, **kwargs: Any) -> None:
|
||||
"""Locate the device."""
|
||||
await self._async_send_wrapper_updates(
|
||||
self._action_wrapper, TuyaVacuumAction.LOCATE
|
||||
)
|
||||
await self._async_send_wrapper_updates(self._action_wrapper, "locate")
|
||||
|
||||
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
|
||||
"""Set fan speed."""
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from aiodns.error import DNSError
|
||||
from aiohttp.client_exceptions import ClientConnectionError
|
||||
from uhooapi import Client
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
from uhooapi.errors import UhooError, UnauthorizedError
|
||||
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryError, 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, ForbiddenError) as err:
|
||||
raise ConfigEntryAuthFailed(f"Invalid API credentials: {err}") from err
|
||||
except UnauthorizedError as err:
|
||||
raise ConfigEntryError(f"Invalid API credentials: {err}") from err
|
||||
except UhooError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Custom uhoo config flow setup."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from uhooapi import Client
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
from uhooapi.errors import UhooError, UnauthorizedError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -46,7 +45,7 @@ class UhooConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
client = Client(user_input[CONF_API_KEY], session, debug=True)
|
||||
try:
|
||||
await client.login()
|
||||
except UnauthorizedError, ForbiddenError:
|
||||
except UnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except UhooError:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -66,39 +65,3 @@ 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,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Custom uhoo data update coordinator."""
|
||||
|
||||
from uhooapi import Client, Device
|
||||
from uhooapi.errors import ForbiddenError, UhooError, UnauthorizedError
|
||||
from uhooapi.errors import UhooError
|
||||
|
||||
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
|
||||
@@ -35,8 +34,6 @@ 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:
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/uhooair",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["uhooapi==1.2.8"]
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,14 +9,6 @@
|
||||
"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%]"
|
||||
|
||||
@@ -11,12 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.EVENT,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.EVENT, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: UnifiAccessConfigEntry) -> bool:
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""Binary sensor platform for the UniFi Access integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unifi_access_api import Door, DoorPositionStatus
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
from .entity import UnifiAccessEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: UnifiAccessConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up UniFi Access binary sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessDoorPositionBinarySensor(coordinator, door)
|
||||
for door in coordinator.data.doors.values()
|
||||
)
|
||||
|
||||
|
||||
class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity):
|
||||
"""Representation of a UniFi Access door position binary sensor."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_device_class = BinarySensorDeviceClass.DOOR
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: UnifiAccessCoordinator,
|
||||
door: Door,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor entity."""
|
||||
super().__init__(coordinator, door, "access_door_dps")
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the door is open."""
|
||||
return self._door.door_position_status == DoorPositionStatus.OPEN
|
||||
@@ -78,7 +78,6 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
data,
|
||||
session,
|
||||
)
|
||||
self._session = session
|
||||
|
||||
# Last resort as no MAC or S/N can be retrieved via API
|
||||
self._id = config_entry.unique_id
|
||||
@@ -136,15 +135,11 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
_LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host)
|
||||
|
||||
try:
|
||||
if not self._session.cookie_jar.filter_cookies(self.api.base_url):
|
||||
_LOGGER.debug(
|
||||
"Session cookies missing for host %s, re-login",
|
||||
self.api.base_url.host,
|
||||
)
|
||||
await self.api.login()
|
||||
await self.api.login()
|
||||
raw_data_devices = await self.api.get_devices_data()
|
||||
data_sensors = await self.api.get_sensor_data()
|
||||
data_wifi = await self.api.get_wifi_data()
|
||||
await self.api.logout()
|
||||
except exceptions.CannotAuthenticate as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -104,7 +104,6 @@ class VodafoneSwitchEntity(CoordinatorEntity[VodafoneStationRouter], SwitchEntit
|
||||
await self.coordinator.api.set_wifi_status(
|
||||
status, self.entity_description.typology, self.entity_description.band
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
except CannotAuthenticate as err:
|
||||
self.coordinator.config_entry.async_start_reauth(self.hass)
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -22,12 +22,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
|
||||
@@ -55,12 +55,6 @@
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"pipeline_n": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::pipeline_n::name%]",
|
||||
"state": {
|
||||
"preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]"
|
||||
}
|
||||
},
|
||||
"vad_sensitivity": {
|
||||
"name": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::name%]",
|
||||
"state": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user