Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185
6c8268f9d8 use the fixture fc_class_mock to the created config entry doesn't open a socket 2026-03-15 11:43:38 +00:00
180 changed files with 2488 additions and 8003 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.23.1
rev: v1.22.0
hooks:
- id: zizmor
args:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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"
}
}
}
}

View File

@@ -25,11 +25,6 @@
}
},
"entity": {
"binary_sensor": {
"incoming_video_interlaced": {
"name": "Incoming video interlaced"
}
},
"sensor": {
"incoming_audio_config": {
"name": "Incoming audio configuration",

View File

@@ -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

View File

@@ -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": {

View File

@@ -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"
]
}

View File

@@ -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
),
}

View File

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

View File

@@ -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"
],

View File

@@ -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)

View File

@@ -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%]"
}
}
}
},

View File

@@ -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": [
{

View File

@@ -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

View File

@@ -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)

View File

@@ -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"]
}

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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": {

View File

@@ -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),

View File

@@ -18,9 +18,9 @@ from homeassistant.components.weather import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
)
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
@@ -38,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,
}

View File

@@ -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"]
}

View File

@@ -6,9 +6,9 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
@@ -28,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),
}

View File

@@ -12,6 +12,6 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pylitterbot"],
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["pylitterbot==2025.1.0"]
}

View File

@@ -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

View File

@@ -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.",

View File

@@ -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):

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.2.2"]
"requirements": ["aiomealie==1.2.1"]
}

View File

@@ -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,
}

View File

@@ -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(

View File

@@ -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()

View File

@@ -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:

View File

@@ -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,
}

View File

@@ -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(

View File

@@ -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(

View File

@@ -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] = {}

View File

@@ -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",
]

View File

@@ -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:

View File

@@ -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%]",

View File

@@ -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,
)
}

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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={

View File

@@ -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%]",

View File

@@ -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)

View File

@@ -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",

View File

@@ -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",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==13.2.2"]
"requirements": ["ical==13.2.0"]
}

View File

@@ -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."

View File

@@ -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)

View File

@@ -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"

View File

@@ -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"
}
},

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
"""Support for the Switchbot (Battery) Circulator fan."""
"""Support for the Switchbot Battery Circulator fan."""
import asyncio
import logging
@@ -43,7 +43,7 @@ async def async_setup_entry(
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
"""Representation of a SwitchBot (Battery) Circulator Fan."""
"""Representation of a SwitchBot Battery Circulator Fan."""
_attr_name = None
@@ -110,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),

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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:

View File

@@ -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()

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["trmnl==0.1.1"]
"requirements": ["trmnl==0.1.0"]
}

View File

@@ -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

View File

@@ -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):

View File

@@ -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."
},

View File

@@ -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(

View File

@@ -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()

View File

@@ -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")

View File

@@ -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)."""

View File

@@ -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."""

View File

@@ -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: (

View File

@@ -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:

View File

@@ -9,8 +9,8 @@ from tuya_device_handlers.device_wrapper.base import DeviceWrapper
from tuya_device_handlers.device_wrapper.common import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
from tuya_device_handlers.device_wrapper.extended import DPCodeRoundedIntegerWrapper
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.humidifier import (
@@ -29,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
),
)

View File

@@ -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,

View File

@@ -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"
]
}

View File

@@ -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."""

View File

@@ -3,11 +3,11 @@
from aiodns.error import DNSError
from aiohttp.client_exceptions import ClientConnectionError
from uhooapi import Client
from uhooapi.errors import 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

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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%]"

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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(

View File

@@ -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": {

View File

@@ -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