Compare commits

..

2 Commits

Author SHA1 Message Date
mib1185
19c059b1e9 fix it 2026-03-09 20:28:35 +00:00
mib1185
4d4241eff2 add is_closed state attribute to valve entity 2026-03-09 18:55:42 +00:00
284 changed files with 1437 additions and 14725 deletions

View File

@@ -196,7 +196,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -328,7 +328,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -406,13 +406,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -585,7 +585,7 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}

View File

@@ -609,7 +609,7 @@ jobs:
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
with:
license-check: false # We use our own license audit checks

View File

@@ -342,7 +342,6 @@ homeassistant.components.lookin.*
homeassistant.components.lovelace.*
homeassistant.components.luftdaten.*
homeassistant.components.lunatone.*
homeassistant.components.lutron.*
homeassistant.components.madvr.*
homeassistant.components.manual.*
homeassistant.components.mastodon.*

4
CODEOWNERS generated
View File

@@ -577,8 +577,6 @@ build.json @home-assistant/supervisor
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -745,8 +743,6 @@ build.json @home-assistant/supervisor
/tests/components/huisbaasje/ @dennisschroer
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
/tests/components/humidifier/ @home-assistant/core @Shulyaka
/homeassistant/components/humidity/ @home-assistant/core
/tests/components/humidity/ @home-assistant/core
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
/homeassistant/components/husqvarna_automower/ @Thomas55555

View File

@@ -243,8 +243,6 @@ DEFAULT_INTEGRATIONS = {
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

40
homeassistant/components/adax/climate.py Executable file → Normal file
View File

@@ -168,57 +168,29 @@ class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
if hvac_mode == HVACMode.HEAT:
temperature = self._attr_target_temperature or self._attr_min_temp
await self._adax_data_handler.set_target_temperature(temperature)
self._attr_target_temperature = temperature
self._attr_icon = "mdi:radiator"
elif hvac_mode == HVACMode.OFF:
await self._adax_data_handler.set_target_temperature(0)
self._attr_icon = "mdi:radiator-off"
else:
# Ignore unsupported HVAC modes to avoid desynchronizing entity state
# from the physical device.
return
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
return
if self._attr_hvac_mode == HVACMode.HEAT:
await self._adax_data_handler.set_target_temperature(temperature)
await self._adax_data_handler.set_target_temperature(temperature)
self._attr_target_temperature = temperature
self.async_write_ha_state()
def _update_hvac_attributes(self) -> None:
"""Update hvac mode and temperatures from coordinator data.
The coordinator reports a target temperature of 0 when the heater is
turned off. In that case, only the hvac mode and icon are updated and
the previous non-zero target temperature is preserved. When the
reported target temperature is non-zero, the stored target temperature
is updated to match the coordinator value.
"""
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if data := self.coordinator.data:
self._attr_current_temperature = data["current_temperature"]
self._attr_available = self._attr_current_temperature is not None
if (target_temp := data["target_temperature"]) == 0:
self._attr_hvac_mode = HVACMode.OFF
self._attr_icon = "mdi:radiator-off"
if self._attr_target_temperature is None:
if target_temp == 0:
self._attr_target_temperature = self._attr_min_temp
else:
self._attr_hvac_mode = HVACMode.HEAT
self._attr_icon = "mdi:radiator"
self._attr_target_temperature = target_temp
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._update_hvac_attributes()
super()._handle_coordinator_update()
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._update_hvac_attributes()

View File

@@ -101,10 +101,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
assert method is not None
await method(self.device, state)
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
self.async_write_ha_state()
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pyanglianwater.meter import SmartMeter
@@ -33,14 +32,13 @@ class AnglianWaterSensor(StrEnum):
YESTERDAY_WATER_COST = "yesterday_water_cost"
YESTERDAY_SEWERAGE_COST = "yesterday_sewerage_cost"
LATEST_READING = "latest_reading"
LAST_UPDATED = "last_updated"
@dataclass(frozen=True, kw_only=True)
class AnglianWaterSensorEntityDescription(SensorEntityDescription):
"""Describes AnglianWater sensor entity."""
value_fn: Callable[[SmartMeter], float | datetime | None]
value_fn: Callable[[SmartMeter], float]
ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
@@ -78,13 +76,6 @@ ENTITY_DESCRIPTIONS: tuple[AnglianWaterSensorEntityDescription, ...] = (
translation_key=AnglianWaterSensor.YESTERDAY_SEWERAGE_COST,
entity_category=EntityCategory.DIAGNOSTIC,
),
AnglianWaterSensorEntityDescription(
key=AnglianWaterSensor.LAST_UPDATED,
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda entity: entity.last_updated,
translation_key=AnglianWaterSensor.LAST_UPDATED,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
@@ -121,6 +112,6 @@ class AnglianWaterSensorEntity(AnglianWaterEntity, SensorEntity):
self.entity_description = description
@property
def native_value(self) -> float | datetime | None:
def native_value(self) -> float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.smart_meter)

View File

@@ -34,9 +34,6 @@
},
"entity": {
"sensor": {
"last_updated": {
"name": "Last meter reading processed"
},
"latest_reading": {
"name": "Latest reading"
},

View File

@@ -8,11 +8,19 @@ from typing import Any
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DEFAULT_SCAN_INTERVAL
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator, ArcamFmjRuntimeData
from .const import (
DEFAULT_SCAN_INTERVAL,
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
)
type ArcamFmjConfigEntry = ConfigEntry[Client]
_LOGGER = logging.getLogger(__name__)
@@ -22,41 +30,24 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
"""Set up config entry."""
client = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
coordinators: dict[int, ArcamFmjCoordinator] = {}
for zone in (1, 2):
coordinator = ArcamFmjCoordinator(hass, entry, client, zone)
coordinators[zone] = coordinator
entry.runtime_data = ArcamFmjRuntimeData(client, coordinators)
entry.runtime_data = Client(entry.data[CONF_HOST], entry.data[CONF_PORT])
entry.async_create_background_task(
hass,
_run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL),
"arcam_fmj",
hass, _run_client(hass, entry.runtime_data, DEFAULT_SCAN_INTERVAL), "arcam_fmj"
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ArcamFmjConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Cleanup before removing config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def _run_client(
hass: HomeAssistant,
runtime_data: ArcamFmjRuntimeData,
interval: float,
) -> None:
client = runtime_data.client
coordinators = runtime_data.coordinators
async def _run_client(hass: HomeAssistant, client: Client, interval: float) -> None:
def _listen(_: Any) -> None:
for coordinator in coordinators.values():
coordinator.async_notify_data_updated()
async_dispatcher_send(hass, SIGNAL_CLIENT_DATA, client.host)
while True:
try:
@@ -64,21 +55,16 @@ async def _run_client(
await client.start()
_LOGGER.debug("Client connected %s", client.host)
async_dispatcher_send(hass, SIGNAL_CLIENT_STARTED, client.host)
try:
for coordinator in coordinators.values():
await coordinator.state.start()
with client.listen(_listen):
for coordinator in coordinators.values():
coordinator.async_notify_connected()
await client.process()
finally:
await client.stop()
_LOGGER.debug("Client disconnected %s", client.host)
for coordinator in coordinators.values():
coordinator.async_notify_disconnected()
async_dispatcher_send(hass, SIGNAL_CLIENT_STOPPED, client.host)
except ConnectionFailed:
await asyncio.sleep(interval)

View File

@@ -2,6 +2,10 @@
DOMAIN = "arcam_fmj"
SIGNAL_CLIENT_STARTED = "arcam.client_started"
SIGNAL_CLIENT_STOPPED = "arcam.client_stopped"
SIGNAL_CLIENT_DATA = "arcam.client_data"
EVENT_TURN_ON = "arcam_fmj.turn_on"
DEFAULT_PORT = 50000

View File

@@ -1,96 +0,0 @@
"""Coordinator for Arcam FMJ integration."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from arcam.fmj import ConnectionFailed
from arcam.fmj.client import Client
from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@dataclass
class ArcamFmjRuntimeData:
"""Runtime data for Arcam FMJ integration."""
client: Client
coordinators: dict[int, ArcamFmjCoordinator]
type ArcamFmjConfigEntry = ConfigEntry[ArcamFmjRuntimeData]
class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for a single Arcam FMJ zone."""
config_entry: ArcamFmjConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ArcamFmjConfigEntry,
client: Client,
zone: int,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Arcam FMJ zone {zone}",
)
self.client = client
self.state = State(client, zone)
self.last_update_success = False
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
name += f" Zone {zone}"
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=name,
)
if zone != 1:
self.device_info["via_device"] = (DOMAIN, unique_id)
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:
await self.state.update()
except ConnectionFailed as err:
raise UpdateFailed(
f"Connection failed during update for zone {self.state.zn}"
) from err
@callback
def async_notify_data_updated(self) -> None:
"""Notify that new data has been received from the device."""
self.async_set_updated_data(None)
@callback
def async_notify_connected(self) -> None:
"""Handle client connected."""
self.hass.async_create_task(self.async_refresh())
@callback
def async_notify_disconnected(self) -> None:
"""Handle client disconnected."""
self.last_update_success = False
self.async_update_listeners()

View File

@@ -8,6 +8,7 @@ import logging
from typing import Any
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj.state import State
from homeassistant.components.media_player import (
BrowseError,
@@ -19,13 +20,20 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from . import ArcamFmjConfigEntry
from .const import (
DOMAIN,
EVENT_TURN_ON,
SIGNAL_CLIENT_DATA,
SIGNAL_CLIENT_STARTED,
SIGNAL_CLIENT_STOPPED,
)
_LOGGER = logging.getLogger(__name__)
@@ -36,17 +44,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinators = config_entry.runtime_data.coordinators
client = config_entry.runtime_data
async_add_entities(
[
ArcamFmj(
config_entry.title,
coordinators[zone],
State(client, zone),
config_entry.unique_id or config_entry.entry_id,
)
for zone in (1, 2)
],
True,
)
@@ -67,21 +77,21 @@ def convert_exception[**_P, _R](
return _convert_exception
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
class ArcamFmj(MediaPlayerEntity):
"""Representation of a media device."""
_attr_should_poll = False
_attr_has_entity_name = True
def __init__(
self,
device_name: str,
coordinator: ArcamFmjCoordinator,
state: State,
uuid: str,
) -> None:
"""Initialize device."""
super().__init__(coordinator)
self._state = coordinator.state
self._attr_name = f"Zone {self._state.zn}"
self._state = state
self._attr_name = f"Zone {state.zn}"
self._attr_supported_features = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -92,11 +102,18 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.TURN_ON
)
if self._state.zn == 1:
if state.zn == 1:
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = coordinator.device_info
self._attr_unique_id = f"{uuid}-{state.zn}"
self._attr_entity_registry_enabled_default = state.zn == 1
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, uuid),
},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=device_name,
)
@property
def state(self) -> MediaPlayerState:
@@ -105,6 +122,49 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
return MediaPlayerState.ON
return MediaPlayerState.OFF
async def async_added_to_hass(self) -> None:
"""Once registered, add listener for events."""
await self._state.start()
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during addition: %s", connection)
@callback
def _data(host: str) -> None:
if host == self._state.client.host:
self.async_write_ha_state()
@callback
def _started(host: str) -> None:
if host == self._state.client.host:
self.async_schedule_update_ha_state(force_refresh=True)
@callback
def _stopped(host: str) -> None:
if host == self._state.client.host:
self.async_schedule_update_ha_state(force_refresh=True)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_DATA, _data)
)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STARTED, _started)
)
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CLIENT_STOPPED, _stopped)
)
async def async_update(self) -> None:
"""Force update of state."""
_LOGGER.debug("Update state %s", self.name)
try:
await self._state.update()
except ConnectionFailed as connection:
_LOGGER.debug("Connection lost during update: %s", connection)
@convert_exception
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""

View File

@@ -137,6 +137,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"button",
"climate",
"cover",
@@ -144,10 +145,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"door",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"input_boolean",
"lawn_mower",
"light",
"lock",

View File

@@ -32,7 +32,6 @@ from homeassistant.helpers import (
issue_registry as ir,
start,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.async_iterator import AsyncIteratorReader
@@ -79,8 +78,6 @@ from .util import (
validate_password_stream,
)
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
@@ -593,49 +590,23 @@ class BackupManager:
)
agent = self.backup_agents[agent_id]
latest_uploaded_bytes = 0
@callback
def _emit_upload_progress() -> None:
"""Emit the latest upload progress event."""
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=latest_uploaded_bytes,
uploaded_bytes=bytes_uploaded,
total_bytes=_backup.size,
)
)
upload_progress_debouncer: Debouncer[None] = Debouncer(
self.hass,
LOGGER,
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
immediate=True,
function=_emit_upload_progress,
)
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
nonlocal latest_uploaded_bytes
latest_uploaded_bytes = bytes_uploaded
upload_progress_debouncer.async_schedule_call()
await agent.async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
on_progress=on_upload_progress,
)
upload_progress_debouncer.async_cancel()
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=_backup.size,
total_bytes=_backup.size,
)
)
if streamer:
await streamer.wait()

View File

@@ -174,5 +174,13 @@
"on": "mdi:window-open"
}
}
},
"triggers": {
"occupancy_cleared": {
"trigger": "mdi:home-outline"
},
"occupancy_detected": {
"trigger": "mdi:home"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description_occupancy": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_bat_low": "{entity_name} battery is low",
@@ -317,5 +321,36 @@
}
}
},
"title": "Binary sensor"
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Binary sensor",
"triggers": {
"occupancy_cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_occupancy%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -0,0 +1,67 @@
"""Provides triggers for binary sensors."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTargetStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class BinarySensorOnOffTrigger(EntityTargetStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domains = {DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
def make_binary_sensor_trigger(
device_class: BinarySensorDeviceClass | None,
to_state: str,
) -> type[BinarySensorOnOffTrigger]:
"""Create an entity state trigger class."""
class CustomTrigger(BinarySensorOnOffTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
_to_states = {to_state}
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"occupancy_detected": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
),
"occupancy_cleared": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for binary sensors."""
return TRIGGERS

View File

@@ -10,16 +10,16 @@
- last
- any
closed:
occupancy_cleared:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: gate
domain: binary_sensor
device_class: occupancy
opened:
occupancy_detected:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: gate
domain: binary_sensor
device_class: occupancy

View File

@@ -37,8 +37,8 @@
"name": "Entity"
},
"speed": {
"description": "The fan speed as a percentage.",
"name": "Fan speed"
"description": "Fan Speed as %.",
"name": "Fan Speed"
}
},
"name": "Set fan speed tracked state"
@@ -47,7 +47,7 @@
"description": "Sets the tracked brightness state of a Bond light.",
"fields": {
"brightness": {
"description": "The tracked brightness of the light.",
"description": "Brightness.",
"name": "Brightness"
},
"entity_id": {
@@ -79,22 +79,22 @@
"name": "Entity"
},
"power_state": {
"description": "The tracked power state.",
"description": "Power state.",
"name": "Power state"
}
},
"name": "Set switch power tracked state"
},
"start_decreasing_brightness": {
"description": "Starts decreasing the brightness of a light (deprecated).",
"description": "Starts decreasing the brightness of the light (deprecated).",
"name": "Start decreasing brightness"
},
"start_increasing_brightness": {
"description": "Starts increasing the brightness of a light (deprecated).",
"description": "Starts increasing the brightness of the light (deprecated).",
"name": "Start increasing brightness"
},
"stop": {
"description": "Stops any in-progress action and empties the queue (deprecated).",
"description": "Stops any in-progress action and empty the queue (deprecated).",
"name": "[%key:common::action::stop%]"
}
}

View File

@@ -53,16 +53,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
DOMAIN, ATTR_HUMIDITY
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
DOMAIN, ATTR_HUMIDITY
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -45,7 +45,7 @@ from .const import (
CoverEntityFeature,
CoverState,
)
from .trigger import make_cover_closed_trigger, make_cover_opened_trigger
from .trigger import CoverClosedTriggerBase, CoverOpenedTriggerBase
_LOGGER = logging.getLogger(__name__)
@@ -74,13 +74,13 @@ __all__ = [
"INTENT_OPEN_COVER",
"PLATFORM_SCHEMA",
"PLATFORM_SCHEMA_BASE",
"CoverClosedTriggerBase",
"CoverDeviceClass",
"CoverEntity",
"CoverEntityDescription",
"CoverEntityFeature",
"CoverOpenedTriggerBase",
"CoverState",
"make_cover_closed_trigger",
"make_cover_opened_trigger",
]

View File

@@ -108,37 +108,5 @@
"toggle_cover_tilt": {
"service": "mdi:arrow-top-right-bottom-left"
}
},
"triggers": {
"awning_closed": {
"trigger": "mdi:storefront-outline"
},
"awning_opened": {
"trigger": "mdi:storefront-outline"
},
"blind_closed": {
"trigger": "mdi:blinds-horizontal-closed"
},
"blind_opened": {
"trigger": "mdi:blinds-horizontal"
},
"curtain_closed": {
"trigger": "mdi:curtains-closed"
},
"curtain_opened": {
"trigger": "mdi:curtains"
},
"shade_closed": {
"trigger": "mdi:roller-shade-closed"
},
"shade_opened": {
"trigger": "mdi:roller-shade"
},
"shutter_closed": {
"trigger": "mdi:window-shutter"
},
"shutter_opened": {
"trigger": "mdi:window-shutter-open"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"close": "Close {entity_name}",
@@ -86,15 +82,6 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -149,107 +136,5 @@
"name": "Toggle tilt"
}
},
"title": "Cover",
"triggers": {
"awning_closed": {
"description": "Triggers after one or more awnings close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Awning closed"
},
"awning_opened": {
"description": "Triggers after one or more awnings open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Awning opened"
},
"blind_closed": {
"description": "Triggers after one or more blinds close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Blind closed"
},
"blind_opened": {
"description": "Triggers after one or more blinds open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Blind opened"
},
"curtain_closed": {
"description": "Triggers after one or more curtains close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Curtain closed"
},
"curtain_opened": {
"description": "Triggers after one or more curtains open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Curtain opened"
},
"shade_closed": {
"description": "Triggers after one or more shades close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shade closed"
},
"shade_opened": {
"description": "Triggers after one or more shades open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shade opened"
},
"shutter_closed": {
"description": "Triggers after one or more shutters close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shutter closed"
},
"shutter_opened": {
"description": "Triggers after one or more shutters open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
"name": "Shutter opened"
}
}
"title": "Cover"
}

View File

@@ -1,19 +1,30 @@
"""Provides triggers for covers."""
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.trigger import (
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityTriggerBase
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
from .const import ATTR_IS_CLOSED, DOMAIN
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class CoverTriggerBase(EntityTriggerBase):
"""Base trigger for cover state changes."""
_domains = {BINARY_SENSOR_DOMAIN, DOMAIN}
_binary_sensor_target_state: str
_cover_is_closed_target_value: bool
_device_classes: dict[str, str]
@@ -48,60 +59,15 @@ class CoverTriggerBase(EntityTriggerBase):
return from_state.state != to_state.state
def make_cover_opened_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_opened."""
class CoverOpenedTriggerBase(CoverTriggerBase):
"""Base trigger for cover opened state changes."""
class CoverOpenedTrigger(CoverTriggerBase):
"""Trigger for cover opened state changes."""
_binary_sensor_target_state = STATE_ON
_cover_is_closed_target_value = False
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverOpenedTrigger
_binary_sensor_target_state = STATE_ON
_cover_is_closed_target_value = False
def make_cover_closed_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
) -> type[CoverTriggerBase]:
"""Create a trigger cover_closed."""
class CoverClosedTriggerBase(CoverTriggerBase):
"""Base trigger for cover closed state changes."""
class CoverClosedTrigger(CoverTriggerBase):
"""Trigger for cover closed state changes."""
_binary_sensor_target_state = STATE_OFF
_cover_is_closed_target_value = True
_domains = domains or {DOMAIN}
_device_classes = device_classes
return CoverClosedTrigger
# Concrete triggers for cover device classes (cover-only, no binary sensor)
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
TRIGGERS: dict[str, type[Trigger]] = {
"awning_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_AWNING),
"awning_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_AWNING),
"blind_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_BLIND),
"blind_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_BLIND),
"curtain_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
"curtain_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
"shade_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHADE),
"shade_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHADE),
"shutter_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
"shutter_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS
_binary_sensor_target_state = STATE_OFF
_cover_is_closed_target_value = True

View File

@@ -1,81 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
awning_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: awning
awning_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: awning
blind_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: blind
blind_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: blind
curtain_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: curtain
curtain_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: curtain
shade_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shade
shade_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shade
shutter_closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shutter
shutter_opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: shutter

View File

@@ -6,9 +6,9 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverClosedTriggerBase,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
CoverOpenedTriggerBase,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
@@ -19,15 +19,21 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
}
class DoorOpenedTrigger(CoverOpenedTriggerBase):
"""Trigger for door opened state changes."""
_device_classes = DEVICE_CLASSES_DOOR
class DoorClosedTrigger(CoverClosedTriggerBase):
"""Trigger for door closed state changes."""
_device_classes = DEVICE_CLASSES_DOOR
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"opened": DoorOpenedTrigger,
"closed": DoorClosedTrigger,
}

View File

@@ -10,7 +10,6 @@ from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,

View File

@@ -1,101 +0,0 @@
"""EHEIM Digital binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.reeflex import EheimDigitalReeflexUV
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
BinarySensorEntityDescription
):
"""Class describing EHEIM Digital binary sensor entities."""
value_fn: Callable[[_DeviceT], bool | None]
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_lighting",
translation_key="is_lighting",
value_fn=lambda device: device.is_lighting,
device_class=BinarySensorDeviceClass.LIGHT,
),
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_uvc_connected",
translation_key="is_uvc_connected",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda device: device.is_uvc_connected,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the binary sensor entities for one or multiple devices."""
entities: list[EheimDigitalBinarySensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalReeflexUV):
entities += [
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], BinarySensorEntity
):
"""Represent an EHEIM Digital binary sensor entity."""
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT,
description: EheimDigitalBinarySensorDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital binary sensor entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
def _async_update_attrs(self) -> None:
self._attr_is_on = self.entity_description.value_fn(self._device)

View File

@@ -1,19 +1,5 @@
{
"entity": {
"binary_sensor": {
"is_lighting": {
"default": "mdi:lightbulb-outline",
"state": {
"on": "mdi:lightbulb-on"
}
},
"is_uvc_connected": {
"default": "mdi:lightbulb-off",
"state": {
"on": "mdi:lightbulb-outline"
}
}
},
"number": {
"day_speed": {
"default": "mdi:weather-sunny"

View File

@@ -33,17 +33,6 @@
}
},
"entity": {
"binary_sensor": {
"is_lighting": {
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"is_uvc_connected": {
"name": "UVC lamp connected"
}
},
"climate": {
"heater": {
"state_attributes": {

View File

@@ -363,12 +363,10 @@ class EvoController(EvoClimateEntity):
Data validation is not required, it will have been done upstream.
"""
if service == EvoService.RESET_SYSTEM:
await self.coordinator.call_client_api(self._evo_device.reset())
return
mode = data[ATTR_MODE] # otherwise it is EvoService.SET_SYSTEM_MODE
if service == EvoService.SET_SYSTEM_MODE:
mode = data[ATTR_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM
mode = EvoSystemMode.AUTO_WITH_RESET
if ATTR_PERIOD in data:
until = dt_util.start_of_local_day()

View File

@@ -27,6 +27,7 @@ from .coordinator import EvoDataUpdateCoordinator
# because supported modes can vary for edge-case systems
# Zone service schemas (registered as entity services)
CLEAR_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {}
SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
@@ -46,7 +47,7 @@ def _register_zone_entity_services(hass: HomeAssistant) -> None:
DOMAIN,
EvoService.CLEAR_ZONE_OVERRIDE,
entity_domain=CLIMATE_DOMAIN,
schema=None,
schema=CLEAR_ZONE_OVERRIDE_SCHEMA,
func="async_clear_zone_override",
)
service.async_register_platform_entity_service(
@@ -78,6 +79,7 @@ def setup_service_functions(
@verify_domain_control(DOMAIN)
async def set_system_mode(call: ServiceCall) -> None:
"""Set the system mode."""
assert coordinator.tcs is not None # mypy
payload = {
"unique_id": coordinator.tcs.id,
@@ -89,11 +91,18 @@ def setup_service_functions(
assert coordinator.tcs is not None # mypy
hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh)
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
# Enumerate which operating modes are supported by this system
modes = list(coordinator.tcs.allowed_system_modes)
# Not all systems support "AutoWithReset": register this handler only if required
if any(
m[SZ_SYSTEM_MODE]
for m in modes
if m[SZ_SYSTEM_MODE] == EvoSystemMode.AUTO_WITH_RESET
):
hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode)
system_mode_schemas = []
modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET]

View File

@@ -111,7 +111,7 @@ def get_model_selection_schema(
),
vol.Required(
CONF_BACKEND,
default=options.get(CONF_BACKEND, "s2-pro"),
default=options.get(CONF_BACKEND, "s1"),
): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
]
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
LATENCY_OPTIONS = ["normal", "balanced"]

View File

@@ -6,9 +6,9 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverClosedTriggerBase,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
CoverOpenedTriggerBase,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
@@ -19,15 +19,21 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
}
class GarageDoorOpenedTrigger(CoverOpenedTriggerBase):
"""Trigger for garage door opened state changes."""
_device_classes = DEVICE_CLASSES_GARAGE_DOOR
class GarageDoorClosedTrigger(CoverClosedTriggerBase):
"""Trigger for garage door closed state changes."""
_device_classes = DEVICE_CLASSES_GARAGE_DOOR
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"opened": GarageDoorOpenedTrigger,
"closed": GarageDoorClosedTrigger,
}

View File

@@ -2,18 +2,12 @@
from __future__ import annotations
import asyncio
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime
from gardena_bluetooth.exceptions import CommunicationFailure
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -29,7 +23,6 @@ from .coordinator import (
GardenaBluetoothConfigEntry,
GardenaBluetoothCoordinator,
)
from .util import async_get_product_type
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -58,41 +51,22 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
try:
await client.update_timestamp(characteristics, dt_util.now())
except CharacteristicNotFound:
pass
except CharacteristicNoAccess:
LOGGER.debug("No access to update internal time")
async def async_setup_entry(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
client = Client(get_connection(hass, address))
try:
async with asyncio.timeout(TIMEOUT):
product_type = await async_get_product_type(hass, address)
except TimeoutError as exception:
raise ConfigEntryNotReady("Unable to find product type") from exception
client = Client(get_connection(hass, address), product_type)
try:
chars = await client.get_all_characteristics()
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
model = await client.read_char(DeviceInformation.model_number, None)
name = entry.title
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
name = await client.read_char(
DeviceConfiguration.custom_device_name, entry.title
)
uuids = await client.get_all_characteristics_uuid()
await client.update_timestamp(dt_util.now())
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
await client.disconnect()
raise ConfigEntryNotReady(
@@ -109,7 +83,7 @@ async def async_setup_entry(
)
coordinator = GardenaBluetoothCoordinator(
hass, entry, LOGGER, client, set(chars.keys()), device, address
hass, entry, LOGGER, client, uuids, device, address
)
entry.runtime_data = coordinator

View File

@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription(
key=Valve.connected_state.unique_id,
key=Valve.connected_state.uuid,
translation_key="valve_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state,
),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.unique_id,
key=Sensor.connected_state.uuid,
translation_key="sensor_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -60,7 +60,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription(
key=Reset.factory_reset.unique_id,
key=Reset.factory_reset.uuid,
translation_key="factory_reset",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -49,7 +49,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothButton(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==2.1.0"]
"requirements": ["gardena-bluetooth==1.6.0"]
}

View File

@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothNumberEntityDescription(
key=Valve.manual_watering_time.unique_id,
key=Valve.manual_watering_time.uuid,
translation_key="manual_watering_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Valve.remaining_open_time.unique_id,
key=Valve.remaining_open_time.uuid,
translation_key="remaining_open_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=0.0,
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.rain_pause.unique_id,
key=DeviceConfiguration.rain_pause.uuid,
translation_key="rain_pause",
native_unit_of_measurement=UnitOfTime.MINUTES,
mode=NumberMode.BOX,
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.seasonal_adjust.unique_id,
key=DeviceConfiguration.seasonal_adjust.uuid,
translation_key="seasonal_adjust",
native_unit_of_measurement=UnitOfTime.DAYS,
mode=NumberMode.BOX,
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Sensor.threshold.unique_id,
key=Sensor.threshold.uuid,
translation_key="sensor_threshold",
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.BOX,
@@ -117,9 +117,9 @@ async def async_setup_entry(
entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
if Valve.remaining_open_time.uuid in coordinator.characteristics:
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
async_add_entities(entities)

View File

@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothSensorEntityDescription(
key=Valve.activation_reason.unique_id,
key=Valve.activation_reason.uuid,
translation_key="activation_reason",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
char=Valve.activation_reason,
),
GardenaBluetoothSensorEntityDescription(
key=Battery.battery_level.unique_id,
key=Battery.battery_level.uuid,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
char=Battery.battery_level,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.battery_level.unique_id,
key=Sensor.battery_level.uuid,
translation_key="sensor_battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.value.unique_id,
key=Sensor.value.uuid,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.MOISTURE,
native_unit_of_measurement=PERCENTAGE,
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.type.unique_id,
key=Sensor.type.uuid,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.unique_id,
key=Sensor.measurement_timestamp.uuid,
translation_key="sensor_measurement_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -102,9 +102,9 @@ async def async_setup_entry(
entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.char.unique_id in coordinator.characteristics
if description.key in coordinator.characteristics
]
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
if Valve.remaining_open_time.uuid in coordinator.characteristics:
entities.append(GardenaBluetoothRemainSensor(coordinator))
async_add_entities(entities)

View File

@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
"""Representation of a valve switch."""
characteristics = {
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
}
def __init__(
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_translation_key = "state"
self._attr_is_on = None
self._attr_entity_registry_enabled_default = False

View File

@@ -1,51 +0,0 @@
"""Utility functions for Gardena Bluetooth integration."""
import asyncio
from collections.abc import AsyncIterator
from gardena_bluetooth.parse import ManufacturerData, ProductType
from homeassistant.components import bluetooth
async def _async_service_info(
hass, address
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
def _callback(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
return
queue.put_nowait(service_info)
service_info = bluetooth.async_last_service_info(hass, address, True)
if service_info:
yield service_info
cancel = bluetooth.async_register_callback(
hass,
_callback,
{bluetooth.match.ADDRESS: address},
bluetooth.BluetoothScanningMode.ACTIVE,
)
try:
while True:
yield await queue.get()
finally:
cancel()
async def async_get_product_type(hass, address: str) -> ProductType:
"""Wait for enough packets of manufacturer data to get the product type."""
data = ManufacturerData()
async for service_info in _async_service_info(hass, address):
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
product_type = ProductType.from_manufacturer_data(data)
if product_type is not ProductType.UNKNOWN:
return product_type
raise AssertionError("Iterator should have been infinite")

View File

@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
_attr_device_class = ValveDeviceClass.WATER
characteristics = {
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
}
def __init__(
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
def _handle_coordinator_update(self) -> None:
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)

View File

@@ -1,17 +0,0 @@
"""Integration for gate triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "gate"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"closed": {
"trigger": "mdi:gate"
},
"opened": {
"trigger": "mdi:gate-open"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "gate",
"name": "Gate",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/gate",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,38 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Gate",
"triggers": {
"closed": {
"description": "Triggers after one or more gates close.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},
"name": "Gate closed"
},
"opened": {
"description": "Triggers after one or more gates open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},
"name": "Gate opened"
}
}
}

View File

@@ -1,25 +0,0 @@
"""Provides triggers for gates."""
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
DEVICE_CLASSES_GATE: dict[str, str] = {
COVER_DOMAIN: CoverDeviceClass.GATE,
}
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for gates."""
return TRIGGERS

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioghost"],
"quality_scale": "gold",
"quality_scale": "silver",
"requirements": ["aioghost==0.4.0"]
}

View File

@@ -7,13 +7,7 @@ from collections.abc import Callable, Collection, Mapping
import logging
from typing import Any
from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ENTITY_ID,
ATTR_GROUP_ENTITIES,
STATE_OFF,
STATE_ON,
)
from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import (
CALLBACK_TYPE,
Event,
@@ -41,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
class GroupEntity(Entity):
"""Representation of a Group of entities."""
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES})
_unrecorded_attributes = frozenset({ATTR_ENTITY_ID})
_attr_should_poll = False
_entity_ids: list[str]

View File

@@ -20,6 +20,9 @@ from homeassistant.const import (
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -29,7 +32,6 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.group import GenericGroup
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -115,13 +117,47 @@ class LockGroup(GroupEntity, LockEntity):
) -> None:
"""Initialize a lock group."""
self._entity_ids = entity_ids
self.group = GenericGroup(self, entity_ids)
self._attr_supported_features = LockEntityFeature.OPEN
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
async def async_lock(self, **kwargs: Any) -> None:
"""Forward the lock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
_LOGGER.debug("Forwarded lock command: %s", data)
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_LOCK,
data,
blocking=True,
context=self._context,
)
async def async_unlock(self, **kwargs: Any) -> None:
"""Forward the unlock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_UNLOCK,
data,
blocking=True,
context=self._context,
)
async def async_open(self, **kwargs: Any) -> None:
"""Forward the open command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_OPEN,
data,
blocking=True,
context=self._context,
)
@callback
def async_update_group_state(self) -> None:
"""Query all members and determine the lock group state."""

View File

@@ -1,28 +1,4 @@
"""The Growatt server PV inverter sensor integration.
This integration supports two distinct Growatt APIs with different auth models:
Classic API (username/password):
- Authenticates via api.login(), which returns a dict with a "success" key.
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
- A failed login does NOT raise an exception — the return value must be checked.
- The coordinator calls api.login() on every update cycle to maintain the session.
Open API V1 (API token):
- Stateless — no login call, token is sent as a Bearer header on every request.
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
any non-zero error_code raises an exception via _process_response().
- Because the library always raises on error, return-value validation after a
successful V1 API call is unnecessary — if it returned, the token was valid.
Error handling pattern for reauth:
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
→ raise ConfigEntryAuthFailed
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
→ raise ConfigEntryAuthFailed
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
"""The Growatt server PV inverter sensor integration."""
from collections.abc import Mapping
from json import JSONDecodeError
@@ -49,7 +25,6 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
V1_API_ERROR_NO_PRIVILEGE,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
@@ -252,12 +227,8 @@ def get_device_list_v1(
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
) from e
raise ConfigEntryError(
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
@@ -301,7 +272,6 @@ async def async_setup_entry(
# V1 API (token-based, no login needed)
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
api.server_url = url
devices, plant_id = await hass.async_add_executor_job(
get_device_list_v1, api, config
)

View File

@@ -1,6 +1,5 @@
"""Config flow for growatt server integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -32,11 +31,8 @@ from .const import (
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
V1_API_ERROR_NO_PRIVILEGE,
)
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
_LOGGER = logging.getLogger(__name__)
@@ -64,137 +60,6 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
menu_options=["password_auth", "token_auth"],
)
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle reauth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.GrowattApi(
add_random_user_id=True,
agent_identifier=user_input[CONF_USERNAME],
)
api.server_url = server_url
try:
login_response = await self.hass.async_add_executor_job(
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.debug("Network error during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
else:
if not isinstance(login_response, dict):
errors["base"] = ERROR_CANNOT_CONNECT
elif login_response.get("success"):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_URL: server_url,
},
)
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
errors["base"] = ERROR_INVALID_AUTH
else:
errors["base"] = ERROR_CANNOT_CONNECT
elif auth_type == AUTH_API_TOKEN:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
api.server_url = server_url
try:
await self.hass.async_add_executor_job(api.plant_list)
except requests.exceptions.RequestException as ex:
_LOGGER.debug(
"Network error during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
errors["base"] = ERROR_INVALID_AUTH
else:
_LOGGER.debug(
"Growatt V1 API error during reauth: %s (Code: %s)",
err.error_msg or str(err),
err.error_code,
)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug(
"Invalid response format during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_URL: server_url,
},
)
# Determine the current region key from the stored config value.
# Legacy entries may store the region key directly; newer entries store the URL.
stored_url = reauth_entry.data.get(CONF_URL, "")
if stored_url in SERVER_URLS_NAMES:
current_region = stored_url
else:
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME,
default=reauth_entry.data.get(CONF_USERNAME),
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
elif auth_type == AUTH_API_TOKEN:
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
else:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=data_schema,
errors=errors,
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -264,11 +129,9 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
e.error_code,
getattr(e, "error_code", None),
)
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex

View File

@@ -40,17 +40,8 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
# Growatt Classic API error codes
LOGIN_INVALID_AUTH_CODE = "502"
# Growatt Open API V1 error codes
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"

View File

@@ -13,11 +13,7 @@ from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -27,8 +23,6 @@ from .const import (
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
)
from .models import GrowattRuntimeData
@@ -69,7 +63,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
self.api.server_url = self.url
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
@@ -95,14 +88,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# login only required for classic API
if self.api_version == "classic":
login_response = self.api.login(self.username, self.password)
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
"Username, password, or URL may be incorrect"
)
raise UpdateFailed(f"Growatt login failed: {msg}")
self.api.login(self.username, self.password)
if self.device_type == "total":
if self.api_version == "v1":
@@ -114,16 +100,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
try:
total_info = self.api.plant_energy_overview(self.plant_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
f"Error fetching plant energy overview: {err}"
) from err
total_info = self.api.plant_energy_overview(self.plant_id)
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
@@ -145,10 +122,6 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}

View File

@@ -30,7 +30,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold

View File

@@ -3,8 +3,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"no_plants": "No plants have been found on this account"
},
"error": {
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
@@ -14,7 +13,7 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "Server region",
"url": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Enter your Growatt login credentials"
@@ -25,20 +24,10 @@
},
"title": "Select your plant"
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
"token_auth": {
"data": {
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API Token"
"token": "API Token",
"url": "Server region"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"

View File

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

View File

@@ -19,7 +19,7 @@ from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
)
from .entity import HomeConnectEntity
from .entity import HomeConnectEntity, HomeConnectOptionEntity
def should_add_option_entity(
@@ -48,7 +48,7 @@ def _create_option_entities(
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectEntity],
list[HomeConnectOptionEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectEntity],
list[HomeConnectOptionEntity],
]
| None,
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectEntity],
list[HomeConnectOptionEntity],
]
| None = None,
) -> None:

View File

@@ -1,235 +0,0 @@
"""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,
FanEntityDescription,
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__)
PARALLEL_UPDATES = 1
FAN_SPEED_MODE_OPTIONS = {
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
}
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
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 (
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
if appliance_coordinator.data.options
and any(
option in appliance_coordinator.data.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)
else []
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect fan entities."""
setup_home_connect_entry(
hass,
entry,
_get_entities_for_appliance,
async_add_entities,
lambda appliance_coordinator, _: _get_entities_for_appliance(
appliance_coordinator
),
)
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
"""Representation of a Home Connect fan entity."""
def __init__(
self,
coordinator: HomeConnectApplianceCoordinator,
) -> None:
"""Initialize the entity."""
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
super().__init__(
coordinator,
AIR_CONDITIONER_ENTITY_DESCRIPTION,
context_override=(
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
),
)
self.update_preset_mode()
@callback
def _handle_coordinator_update_preset_mode(self) -> None:
"""Handle updated data from the coordinator."""
self.update_preset_mode()
self.async_write_ha_state()
_LOGGER.debug(
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_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._handle_coordinator_update_preset_mode,
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
)
)
def update_native_value(self) -> None:
"""Set the speed percentage and speed mode values."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_percentage = (
cast(int, option_value) if option_value is not None else None
)
@property
def supported_features(self) -> FanEntityFeature:
"""Return the supported features for this fan entity."""
features = FanEntityFeature(0)
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
in self.appliance.options
):
features |= FanEntityFeature.SET_SPEED
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
in self.appliance.options
):
features |= FanEntityFeature.PRESET_MODE
return features
def update_preset_mode(self) -> None:
"""Set the preset mode value."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_preset_mode = (
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
if option_value is not None
else None
)
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
and (
allowed_values_without_none := {
value
for value in option_constraints.allowed_values
if value is not None
}
)
and self._original_speed_modes_keys != allowed_values_without_none
):
self._original_speed_modes_keys = allowed_values_without_none
self._attr_preset_modes = [
key
for key, value in FAN_SPEED_MODE_OPTIONS.items()
if value in self._original_speed_modes_keys
]
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
percentage,
)
_LOGGER.debug(
"Updated %s's speed percentage option, new state: %s",
self.entity_id,
percentage,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target fan mode."""
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,
)
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."""
return super().available and any(
option in self.appliance.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)

View File

@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(appliance_coordinator, description)

View File

@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(appliance_coordinator, desc)

View File

@@ -119,18 +119,6 @@
"name": "Stop program"
}
},
"fan": {
"air_conditioner": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]"
}
}
}
}
},
"light": {
"ambient_light": {
"name": "Ambient light"

View File

@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectEntity]:
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(appliance_coordinator, description)

View File

@@ -1,17 +0,0 @@
"""Integration for humidity triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "humidity"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"changed": {
"trigger": "mdi:water-percent"
},
"crossed_threshold": {
"trigger": "mdi:water-percent"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"domain": "humidity",
"name": "Humidity",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/humidity",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,68 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above",
"below": "Below",
"between": "Between",
"outside": "Outside"
}
}
},
"title": "Humidity",
"triggers": {
"changed": {
"description": "Triggers when the relative humidity changes.",
"fields": {
"above": {
"description": "Only trigger when relative humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when relative humidity is below this value.",
"name": "Below"
}
},
"name": "Relative humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the relative humidity crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
"name": "[%key:component::humidity::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "The lower limit of the threshold.",
"name": "Lower limit"
},
"threshold_type": {
"description": "The type of threshold to use.",
"name": "Threshold type"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Relative humidity crossed threshold"
}
}
}

View File

@@ -1,71 +0,0 @@
"""Provides triggers for humidity."""
from __future__ import annotations
from homeassistant.components.climate import (
ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY,
DOMAIN as CLIMATE_DOMAIN,
)
from homeassistant.components.humidifier import (
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
DOMAIN as HUMIDIFIER_DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _HumidityTriggerMixin(EntityTriggerBase):
"""Mixin for humidity triggers providing entity filtering and value extraction."""
_attributes = {
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
SENSOR_DOMAIN: None, # Use state.state
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
}
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
or get_device_class_or_undefined(self._hass, entity_id)
== SensorDeviceClass.HUMIDITY
}
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
):
"""Trigger for humidity value changes across multiple domains."""
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,
"crossed_threshold": HumidityCrossedThresholdTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for humidity."""
return TRIGGERS

View File

@@ -1,65 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
.trigger_target: &trigger_target
entity:
- domain: sensor
device_class: humidity
- domain: climate
- domain: humidifier
- domain: weather
changed:
target: *trigger_target
fields:
above: *number_or_entity
below: *number_or_entity
crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
# Some mowers only expose the serial number in the manufacturer data
# and not the product type, so we allow None here as well.
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
if product_type not in (ProductType.MOWER, None):
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
return False

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
}

View File

@@ -51,38 +51,6 @@ class IndevoltConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the Indevolt device host."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
# Attempt to setup from user input
if user_input is not None:
errors, device_data = await self._async_validate_input(user_input)
if not errors and device_data:
await self.async_set_unique_id(device_data[CONF_SERIAL_NUMBER])
self._abort_if_unique_id_mismatch(reason="different_device")
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_HOST: user_input[CONF_HOST],
**device_data,
},
)
# Retrieve user input (prefilled form)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_HOST): str}),
reconfigure_entry.data,
),
errors=errors,
)
async def _async_validate_input(
self, user_input: dict[str, Any]
) -> tuple[dict[str, str], dict[str, Any] | None]:

View File

@@ -77,7 +77,8 @@ rules:
status: todo
icon-translations:
status: todo
reconfiguration-flow: done
reconfiguration-flow:
status: todo
repair-issues:
status: exempt
comment: No repair issues needed for current functionality

View File

@@ -2,9 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "Failed to connect (aborted)",
"different_device": "The device at the new host has a different serial number. Please ensure the new host is the same device.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"cannot_connect": "Failed to connect (aborted)"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -12,16 +10,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::indevolt::config::step::user::data_description::host%]"
},
"description": "Update the connection details for your Indevolt device.",
"title": "Reconfigure Indevolt device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

@@ -241,104 +241,6 @@ class InfluxDBConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
entry = self._get_reconfigure_entry()
if entry.data[CONF_API_VERSION] == API_VERSION_2:
return await self.async_step_reconfigure_v2(user_input)
return await self.async_step_reconfigure_v1(user_input)
async def async_step_reconfigure_v1(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of InfluxDB v1."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
url = URL(user_input[CONF_URL])
data = {
CONF_API_VERSION: DEFAULT_API_VERSION,
CONF_HOST: url.host,
CONF_PORT: url.port,
CONF_USERNAME: user_input.get(CONF_USERNAME),
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
CONF_DB_NAME: user_input[CONF_DB_NAME],
CONF_SSL: url.scheme == "https",
CONF_PATH: url.path,
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
}
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
path = await _save_uploaded_cert_file(self.hass, cert)
data[CONF_SSL_CA_CERT] = str(path)
elif CONF_SSL_CA_CERT in entry.data:
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
errors = await _validate_influxdb_connection(self.hass, data)
if not errors:
title = f"{data[CONF_DB_NAME]} ({data[CONF_HOST]})"
return self.async_update_reload_and_abort(
entry, title=title, data_updates=data
)
suggested_values = dict(entry.data) | (user_input or {})
if user_input is None:
suggested_values[CONF_URL] = str(
URL.build(
scheme="https" if entry.data.get(CONF_SSL) else "http",
host=entry.data.get(CONF_HOST, ""),
port=entry.data.get(CONF_PORT),
path=entry.data.get(CONF_PATH, ""),
)
)
return self.async_show_form(
step_id="reconfigure_v1",
data_schema=self.add_suggested_values_to_schema(
INFLUXDB_V1_SCHEMA, suggested_values
),
errors=errors,
)
async def async_step_reconfigure_v2(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of InfluxDB v2."""
errors: dict[str, str] = {}
entry = self._get_reconfigure_entry()
if user_input is not None:
data = {
CONF_API_VERSION: API_VERSION_2,
CONF_URL: user_input[CONF_URL],
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_ORG: user_input[CONF_ORG],
CONF_BUCKET: user_input[CONF_BUCKET],
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
}
if (cert := user_input.get(CONF_SSL_CA_CERT)) is not None:
path = await _save_uploaded_cert_file(self.hass, cert)
data[CONF_SSL_CA_CERT] = str(path)
elif CONF_SSL_CA_CERT in entry.data:
data[CONF_SSL_CA_CERT] = entry.data[CONF_SSL_CA_CERT]
errors = await _validate_influxdb_connection(self.hass, data)
if not errors:
title = f"{data[CONF_BUCKET]} ({data[CONF_URL]})"
return self.async_update_reload_and_abort(
entry, title=title, data_updates=data
)
return self.async_show_form(
step_id="reconfigure_v2",
data_schema=self.add_suggested_values_to_schema(
INFLUXDB_V2_SCHEMA, entry.data | (user_input or {})
),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle the initial step."""
import_data = {**import_data}

View File

@@ -3,9 +3,6 @@
"ssl_ca_cert": "SSL CA certificate (Optional)"
},
"config": {
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
@@ -49,39 +46,6 @@
"import": {
"title": "Import configuration"
},
"reconfigure_v1": {
"data": {
"database": "[%key:component::influxdb::config::step::configure_v1::data::database%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
"url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"database": "[%key:component::influxdb::config::step::configure_v1::data_description::database%]",
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v1::data_description::ssl_ca_cert%]"
},
"description": "Update the connection settings for your InfluxDB v1.x server.",
"title": "[%key:component::influxdb::config::step::configure_v1::title%]"
},
"reconfigure_v2": {
"data": {
"bucket": "[%key:component::influxdb::config::step::configure_v2::data::bucket%]",
"organization": "[%key:component::influxdb::config::step::configure_v2::data::organization%]",
"ssl_ca_cert": "[%key:component::influxdb::common::ssl_ca_cert%]",
"token": "[%key:common::config_flow::data::api_token%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"bucket": "[%key:component::influxdb::config::step::configure_v2::data_description::bucket%]",
"organization": "[%key:component::influxdb::config::step::configure_v2::data_description::organization%]",
"ssl_ca_cert": "[%key:component::influxdb::config::step::configure_v2::data_description::ssl_ca_cert%]"
},
"description": "Update the connection settings for your InfluxDB v2.x / v3 server.",
"title": "[%key:component::influxdb::config::step::configure_v2::title%]"
},
"user": {
"menu_options": {
"configure_v1": "InfluxDB v1.x",

View File

@@ -20,13 +20,5 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted toggles to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -21,15 +17,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -48,27 +35,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more toggles turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned off"
},
"turned_on": {
"description": "Triggers after one or more toggles turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned on"
}
}
"title": "Input boolean"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -6,7 +6,6 @@ import asyncio
from intellifire4py import UnifiedFireplace
from intellifire4py.cloud_interface import IntelliFireCloudInterface
from intellifire4py.const import IntelliFireApiMode
from intellifire4py.model import IntelliFireCommonFireplaceData
from homeassistant.const import (
@@ -21,7 +20,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import (
API_MODE_LOCAL,
CONF_AUTH_COOKIE,
CONF_CONTROL_MODE,
CONF_READ_MODE,
@@ -57,10 +55,8 @@ def _construct_common_data(
serial=entry.data[CONF_SERIAL],
api_key=entry.data[CONF_API_KEY],
ip_address=entry.data[CONF_IP_ADDRESS],
read_mode=IntelliFireApiMode(entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)),
control_mode=IntelliFireApiMode(
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
),
read_mode=entry.options[CONF_READ_MODE],
control_mode=entry.options[CONF_CONTROL_MODE],
)
@@ -101,34 +97,12 @@ async def async_migrate_entry(
hass.config_entries.async_update_entry(
config_entry,
data=new,
options={
CONF_READ_MODE: API_MODE_LOCAL,
CONF_CONTROL_MODE: API_MODE_LOCAL,
},
options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"},
unique_id=new[CONF_SERIAL],
version=1,
minor_version=3,
minor_version=2,
)
LOGGER.debug("Migration to 1.3 successful")
if config_entry.minor_version < 3:
# Migrate old option keys (cloud_read, cloud_control) to new keys
old_options = config_entry.options
new_options = {
CONF_READ_MODE: old_options.get(
"cloud_read", old_options.get(CONF_READ_MODE, API_MODE_LOCAL)
),
CONF_CONTROL_MODE: old_options.get(
"cloud_control", old_options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
),
}
hass.config_entries.async_update_entry(
config_entry,
options=new_options,
version=1,
minor_version=3,
)
LOGGER.debug("Migration to 1.3 successful (options keys renamed)")
LOGGER.debug("Pseudo Migration %s successful", config_entry.version)
return True
@@ -165,43 +139,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_update_options(
hass: HomeAssistant, entry: IntellifireConfigEntry
) -> None:
"""Handle options update."""
coordinator: IntellifireDataUpdateCoordinator = entry.runtime_data
new_read_mode = IntelliFireApiMode(
entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
)
new_control_mode = IntelliFireApiMode(
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
)
fireplace = coordinator.fireplace
current_read_mode = fireplace.read_mode
current_control_mode = fireplace.control_mode
# Only update modes that actually changed
if new_read_mode != current_read_mode:
LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode)
await fireplace.set_read_mode(new_read_mode)
if new_control_mode != current_control_mode:
LOGGER.debug(
"Updating control mode: %s -> %s", current_control_mode, new_control_mode
)
await fireplace.set_control_mode(new_control_mode)
# Refresh data with new mode settings
await coordinator.async_request_refresh()
async def _async_wait_for_initialization(
fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT
):

View File

@@ -13,12 +13,7 @@ from intellifire4py.local_api import IntelliFireAPILocal
from intellifire4py.model import IntelliFireCommonFireplaceData
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
@@ -26,12 +21,9 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers import selector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
API_MODE_CLOUD,
API_MODE_LOCAL,
CONF_AUTH_COOKIE,
CONF_CONTROL_MODE,
@@ -42,7 +34,6 @@ from .const import (
DOMAIN,
LOGGER,
)
from .coordinator import IntellifireConfigEntry
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
@@ -79,7 +70,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for IntelliFire."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the Config Flow Handler."""
@@ -269,85 +260,3 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="not_intellifire_device")
return await self.async_step_cloud_api()
@staticmethod
@callback
def async_get_options_flow(config_entry: IntellifireConfigEntry) -> OptionsFlow:
"""Create the options flow."""
return IntelliFireOptionsFlowHandler()
class IntelliFireOptionsFlowHandler(OptionsFlow):
"""Options flow for IntelliFire component."""
config_entry: IntellifireConfigEntry
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
# Validate connectivity for requested modes if runtime data is available
coordinator = self.config_entry.runtime_data
if coordinator is not None:
fireplace = coordinator.fireplace
# Refresh connectivity status before validating
await fireplace.async_validate_connectivity()
if (
user_input[CONF_READ_MODE] == API_MODE_LOCAL
and not fireplace.local_connectivity
):
errors[CONF_READ_MODE] = "local_unavailable"
if (
user_input[CONF_READ_MODE] == API_MODE_CLOUD
and not fireplace.cloud_connectivity
):
errors[CONF_READ_MODE] = "cloud_unavailable"
if (
user_input[CONF_CONTROL_MODE] == API_MODE_LOCAL
and not fireplace.local_connectivity
):
errors[CONF_CONTROL_MODE] = "local_unavailable"
if (
user_input[CONF_CONTROL_MODE] == API_MODE_CLOUD
and not fireplace.cloud_connectivity
):
errors[CONF_CONTROL_MODE] = "cloud_unavailable"
if not errors:
return self.async_create_entry(title="", data=user_input)
existing_read = self.config_entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
existing_control = self.config_entry.options.get(
CONF_CONTROL_MODE, API_MODE_LOCAL
)
cloud_local_options = selector.SelectSelectorConfig(
options=[API_MODE_LOCAL, API_MODE_CLOUD],
translation_key="api_mode",
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_READ_MODE,
default=user_input.get(CONF_READ_MODE, existing_read)
if user_input
else existing_read,
): selector.SelectSelector(cloud_local_options),
vol.Required(
CONF_CONTROL_MODE,
default=user_input.get(CONF_CONTROL_MODE, existing_control)
if user_input
else existing_control,
): selector.SelectSelector(cloud_local_options),
}
),
errors=errors,
)

View File

@@ -13,8 +13,8 @@ CONF_WEB_CLIENT_ID = "web_client_id" # part of the cloud cookie
CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie
CONF_SERIAL = "serial"
CONF_READ_MODE = "read_mode"
CONF_CONTROL_MODE = "control_mode"
CONF_READ_MODE = "cloud_read"
CONF_CONTROL_MODE = "cloud_control"
API_MODE_LOCAL = "local"

View File

@@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import API_MODE_CLOUD, API_MODE_LOCAL
from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator
from .entity import IntellifireEntity
@@ -67,22 +66,6 @@ def _uptime_to_timestamp(
INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
IntellifireSensorEntityDescription(
key="read_mode",
translation_key="read_mode",
device_class=SensorDeviceClass.ENUM,
options=[API_MODE_LOCAL, API_MODE_CLOUD],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda coordinator: coordinator.fireplace.read_mode.value,
),
IntellifireSensorEntityDescription(
key="control_mode",
translation_key="control_mode",
device_class=SensorDeviceClass.ENUM,
options=[API_MODE_LOCAL, API_MODE_CLOUD],
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda coordinator: coordinator.fireplace.control_mode.value,
),
IntellifireSensorEntityDescription(
key="flame_height",
translation_key="flame_height",

View File

@@ -100,13 +100,6 @@
"connection_quality": {
"name": "Connection quality"
},
"control_mode": {
"name": "Control mode",
"state": {
"cloud": "Cloud",
"local": "Local"
}
},
"downtime": {
"name": "Downtime"
},
@@ -122,13 +115,6 @@
"ipv4_address": {
"name": "IP address"
},
"read_mode": {
"name": "Read mode",
"state": {
"cloud": "Cloud",
"local": "Local"
}
},
"target_temp": {
"name": "Target temperature"
},
@@ -147,29 +133,5 @@
"name": "Pilot light"
}
}
},
"options": {
"error": {
"cloud_unavailable": "Cloud connectivity is not available",
"local_unavailable": "Local connectivity is not available"
},
"step": {
"init": {
"data": {
"control_mode": "Send commands to",
"read_mode": "Read data from"
},
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
"title": "Endpoint selection"
}
}
},
"selector": {
"api_mode": {
"options": {
"cloud": "Cloud",
"local": "Local"
}
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==2.0.3"]
"requirements": ["pyjvcprojector==2.0.2"]
}

View File

@@ -120,19 +120,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
value_type=config[CONF_TYPE],
),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
self._attr_device_class = config.get(
CONF_DEVICE_CLASS,
try_parse_enum(
# sensor device classes should, with some exceptions ("enum" etc.), align with number device classes
NumberDeviceClass,
dpt_info["sensor_device_class"],
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_mode = config[CONF_MODE]
self._attr_native_max_value = config.get(
NumberConf.MAX,
self._device.sensor_value.dpt_class.value_max,
@@ -141,16 +128,14 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
NumberConf.MIN,
self._device.sensor_value.dpt_class.value_min,
)
self._attr_mode = config[CONF_MODE]
self._attr_native_step = config.get(
NumberConf.STEP,
self._device.sensor_value.dpt_class.resolution,
)
self._attr_native_unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._device.sensor_value.value = max(0, self._attr_native_min_value)

View File

@@ -20,10 +20,7 @@ from homeassistant.components.climate import FAN_OFF, HVACMode
from homeassistant.components.cover import (
DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA,
)
from homeassistant.components.number import (
DEVICE_CLASSES_SCHEMA as NUMBER_DEVICE_CLASSES_SCHEMA,
NumberMode,
)
from homeassistant.components.number import NumberMode
from homeassistant.components.sensor import (
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA,
@@ -42,7 +39,6 @@ from homeassistant.const import (
CONF_NAME,
CONF_PAYLOAD,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE_TEMPLATE,
Platform,
)
@@ -791,8 +787,6 @@ class NumberSchema(KNXPlatformSchema):
vol.Optional(NumberConf.MAX): vol.Coerce(float),
vol.Optional(NumberConf.MIN): vol.Coerce(float),
vol.Optional(NumberConf.STEP): cv.positive_float,
vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
@@ -873,7 +867,6 @@ class SensorSchema(KNXPlatformSchema):
vol.Required(CONF_TYPE): sensor_type_validator,
vol.Required(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),

View File

@@ -216,22 +216,20 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
self._attr_device_class = config.get(
CONF_DEVICE_CLASS,
dpt_info["sensor_device_class"],
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = dpt_info["sensor_device_class"]
self._attr_state_class = (
config.get(CONF_STATE_CLASS) or dpt_info["sensor_state_class"]
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_extra_state_attributes = {}
self._attr_native_unit_of_measurement = dpt_info["unit"]
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_native_unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._attr_state_class = config.get(
CONF_STATE_CLASS,
dpt_info["sensor_state_class"],
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_extra_state_attributes = {}
class KnxUiSensor(_KnxSensor, KnxUiEntity):

View File

@@ -62,9 +62,7 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
registry=dr.async_get(self.hass), config_entry_id=self._entry_id
)
self._previous_devices: dict[DeviceId, DeviceName] = {
DeviceId(
next(iter(device.identifiers))[1].removeprefix(f"{self._entry_id}_")
): DeviceName(device.name)
DeviceId(next(iter(device.identifiers))[1]): DeviceName(device.name)
for device in device_entries
if device.identifiers and device.name
}
@@ -111,6 +109,11 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
self, detected_devices: dict[DeviceId, DeviceName]
) -> None:
"""Handle device changes by deleting devices from / adding devices to Home Assistant."""
detected_devices = {
DeviceId(f"{self.config_entry.entry_id}_{detected_id}"): device_name
for detected_id, device_name in detected_devices.items()
}
previous_device_ids = set(self._previous_devices.keys())
detected_device_ids = set(detected_devices.keys())
@@ -128,14 +131,25 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
device_registry = dr.async_get(self.hass)
for device_id in orphaned_devices:
if device := device_registry.async_get_device(
identifiers={(DOMAIN, f"{self._entry_id}_{device_id}")}
identifiers={(DOMAIN, device_id)}
):
_LOGGER.debug(
"Removing device: %s", self._previous_devices[device_id]
)
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self._entry_id,
remove_config_entry_id=self.config_entry.entry_id,
)
if self.data is None:
# initial update during integration startup
self._previous_devices = detected_devices # type: ignore[unreachable]
return
if new_devices := detected_device_ids - previous_device_ids:
_LOGGER.warning(
"New Device(s) detected, reload integration to add them to Home Assistant: %s",
[detected_devices[DeviceId(device_id)] for device_id in new_devices],
)
self._previous_devices = detected_devices

View File

@@ -2,10 +2,9 @@
from __future__ import annotations
import logging
from typing import Any
from librehardwaremonitor_api.model import DeviceId, LibreHardwareMonitorSensorData
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
from librehardwaremonitor_api.sensor_type import SensorType
from homeassistant.components.sensor import SensorEntity, SensorStateClass
@@ -17,8 +16,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
STATE_MIN_VALUE = "min_value"
@@ -33,28 +30,10 @@ async def async_setup_entry(
"""Set up the LibreHardwareMonitor platform."""
lhm_coordinator = config_entry.runtime_data
known_devices: set[DeviceId] = set()
def _check_device() -> None:
current_devices = set(lhm_coordinator.data.main_device_ids_and_names)
new_devices = current_devices - known_devices
if new_devices:
_LOGGER.debug("New Device(s) detected, adding: %s", new_devices)
known_devices.update(new_devices)
new_devices_sensor_data = [
sensor_data
for sensor_data in lhm_coordinator.data.sensor_data.values()
if sensor_data.device_id in new_devices
]
async_add_entities(
LibreHardwareMonitorSensor(
lhm_coordinator, config_entry.entry_id, sensor_data
)
for sensor_data in new_devices_sensor_data
)
_check_device()
config_entry.async_on_unload(lhm_coordinator.async_add_listener(_check_device))
async_add_entities(
LibreHardwareMonitorSensor(lhm_coordinator, config_entry.entry_id, sensor_data)
for sensor_data in lhm_coordinator.data.sensor_data.values()
)
class LibreHardwareMonitorSensor(

View File

@@ -7,7 +7,7 @@ import csv
import dataclasses
import logging
import os
from typing import TYPE_CHECKING, Any, Self, cast, final, override
from typing import TYPE_CHECKING, Any, Self, cast, final
from propcache.api import cached_property
import voluptuous as vol
@@ -272,18 +272,6 @@ def filter_turn_off_params(
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
def process_turn_off_params(
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn off params."""
params = dict(params)
if ATTR_TRANSITION not in params:
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
return params
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
"""Filter out params not supported by the light."""
supported_features = light.supported_features
@@ -318,171 +306,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
return params
def process_turn_on_params( # noqa: C901
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn on params."""
params = dict(params)
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
return params
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Expose light control via state machine and services."""
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
@@ -506,15 +330,177 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
base["params"] = data
return base
async def async_handle_light_on_service(
async def async_handle_light_on_service( # noqa: C901
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning a light on.
If brightness is set to 0, this service will turn the light off.
"""
params = process_turn_on_params(hass, light, call.data["params"])
params: dict[str, Any] = dict(call.data["params"])
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (
params and ATTR_TRANSITION not in params
):
profiles.apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
color_temp
)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
):
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
# Remove deprecated white value if the light supports color mode
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
await async_handle_light_off_service(light, call)
else:
@@ -524,7 +510,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning off a light."""
params = process_turn_off_params(hass, light, call.data["params"])
params = dict(call.data["params"])
if ATTR_TRANSITION not in params:
profiles.apply_default(light.entity_id, True, params)
await light.async_turn_off(**filter_turn_off_params(light, params))
@@ -532,7 +521,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle toggling a light."""
await light.async_toggle(**call.data["params"])
if light.is_on:
await async_handle_light_off_service(light, call)
else:
await async_handle_light_on_service(light, call)
# Listen for light on and light off service calls.
@@ -1054,15 +1046,3 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> LightEntityFeature:
"""Flag supported features."""
return self._attr_supported_features
@override
async def async_toggle(self, **kwargs: Any) -> None:
"""Toggle the entity."""
if not self.is_on:
params = process_turn_on_params(self.hass, self, kwargs)
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
await self.async_turn_on(**filter_turn_on_params(self, params))
return
params = process_turn_off_params(self.hass, self, kwargs)
await self.async_turn_off(**filter_turn_off_params(self, params))

View File

@@ -24,7 +24,7 @@ class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
@@ -35,7 +35,7 @@ class BrightnessCrossedThresholdTrigger(
"""Trigger for brightness crossed threshold."""
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)

View File

@@ -3,15 +3,10 @@
from __future__ import annotations
import itertools
import logging
from pylitterbot import Account
from pylitterbot.exceptions import LitterRobotException
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.typing import ConfigType
@@ -19,8 +14,6 @@ from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -40,50 +33,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_migrate_entry(
hass: HomeAssistant, entry: LitterRobotConfigEntry
) -> bool:
"""Migrate old entry."""
_LOGGER.debug(
"Migrating configuration from version %s.%s",
entry.version,
entry.minor_version,
)
if entry.version > 1:
return False
if entry.minor_version < 2:
account = Account(websession=async_get_clientsession(hass))
try:
await account.connect(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
user_id = account.user_id
except LitterRobotException:
_LOGGER.debug("Could not connect to set unique_id during migration")
return False
finally:
await account.disconnect()
if user_id and not hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, user_id
):
hass.config_entries.async_update_entry(
entry, unique_id=user_id, minor_version=2
)
else:
hass.config_entries.async_update_entry(entry, minor_version=2)
_LOGGER.debug(
"Migration to configuration version %s.%s successful",
entry.version,
entry.minor_version,
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool:
"""Set up Litter-Robot from a config entry."""
coordinator = LitterRobotDataUpdateCoordinator(hass, entry)

View File

@@ -27,10 +27,8 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Litter-Robot."""
VERSION = 1
MINOR_VERSION = 2
username: str
_account_user_id: str | None = None
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -47,8 +45,6 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
user_input = user_input | {CONF_USERNAME: self.username}
if not (error := await self._async_validate_input(user_input)):
await self.async_set_unique_id(self._account_user_id)
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data_updates=user_input
)
@@ -69,9 +65,8 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
if not (error := await self._async_validate_input(user_input)):
await self.async_set_unique_id(self._account_user_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_USERNAME], data=user_input
)
@@ -97,7 +92,4 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
except Exception:
_LOGGER.exception("Unexpected exception")
return "unknown"
self._account_user_id = account.user_id
if not self._account_user_id:
return "unknown"
return ""

View File

@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The Whisker account does not match the previously configured account. Please re-authenticate using the same account, or remove this integration and set it up again if you want to use a different account."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -2,7 +2,6 @@
from dataclasses import dataclass
import logging
from typing import Any, cast
from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output
@@ -43,7 +42,7 @@ class LutronData:
covers: list[tuple[str, Output]]
fans: list[tuple[str, Output]]
lights: list[tuple[str, Output]]
scenes: list[tuple[str, Keypad, Button, Led | None]]
scenes: list[tuple[str, Keypad, Button, Led]]
switches: list[tuple[str, Output]]
@@ -111,14 +110,6 @@ async def async_setup_entry(
)
for keypad in area.keypads:
_async_check_keypad_identifiers(
hass,
device_registry,
keypad.id,
keypad.uuid,
keypad.legacy_uuid,
entry_data.client.guid,
)
for button in keypad.buttons:
# If the button has a function assigned to it, add it as a scene
if button.name != "Unknown Button" and button.button_type in (
@@ -235,36 +226,6 @@ def _async_check_device_identifiers(
)
def _async_check_keypad_identifiers(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
keypad_id: int,
uuid: str,
legacy_uuid: str,
controller_guid: str,
) -> None:
"""Migrate from integer based keypad.ids to proper uuids."""
# First check for the very old integer-based ID
# We use cast(Any, ...) here because legacy devices may have integer identifiers
# in the registry, but modern Home Assistant expects strings.
device = device_registry.async_get_device(
identifiers={(DOMAIN, cast(Any, keypad_id))}
)
if device:
new_unique_id = f"{controller_guid}_{uuid or legacy_uuid}"
_LOGGER.debug("Updating keypad id from %d to %s", keypad_id, new_unique_id)
device_registry.async_update_device(
device.id, new_identifiers={(DOMAIN, new_unique_id)}
)
return
# Now handle legacy_uuid to uuid migration if needed
_async_check_device_identifiers(
hass, device_registry, uuid, legacy_uuid, controller_guid
)
async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool:
"""Clean up resources and entities associated with the integration."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

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