mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 05:51:59 +01:00
Compare commits
1 Commits
PIRUnoccup
...
windows-98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df06a5878c |
10
.github/workflows/builder.yml
vendored
10
.github/workflows/builder.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -243,7 +243,6 @@ DEFAULT_INTEGRATIONS = {
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidity",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,9 +34,6 @@
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"last_updated": {
|
||||
"name": "Last meter reading processed"
|
||||
},
|
||||
"latest_reading": {
|
||||
"name": "Latest reading"
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,76 +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.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_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
|
||||
|
||||
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()
|
||||
@@ -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,14 +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 DOMAIN, 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__)
|
||||
|
||||
@@ -37,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,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,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
|
||||
@@ -93,10 +102,10 @@ 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_unique_id = f"{uuid}-{state.zn}"
|
||||
self._attr_entity_registry_enabled_default = state.zn == 1
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, uuid),
|
||||
@@ -113,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."""
|
||||
|
||||
@@ -137,6 +137,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"binary_sensor",
|
||||
"button",
|
||||
"climate",
|
||||
"cover",
|
||||
@@ -144,10 +145,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
|
||||
@@ -174,5 +174,13 @@
|
||||
"on": "mdi:window-open"
|
||||
}
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"occupancy_cleared": {
|
||||
"trigger": "mdi:home-outline"
|
||||
},
|
||||
"occupancy_detected": {
|
||||
"trigger": "mdi:home"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
homeassistant/components/binary_sensor/trigger.py
Normal file
67
homeassistant/components/binary_sensor/trigger.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"preview_features": { "windows_98": {}, "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260304.0"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"windows_98": {
|
||||
"description": "Transforms your dashboard with a nostalgic Windows 98 look.",
|
||||
"disable_confirmation": "Your dashboard will return to its normal look. You can re-enable this at any time in Labs settings.",
|
||||
"enable_confirmation": "Your dashboard will be transformed with a Windows 98 theme. You can turn this off at any time in Labs settings.",
|
||||
"name": "Windows 98"
|
||||
},
|
||||
"winter_mode": {
|
||||
"description": "Adds falling snowflakes on your screen. Get your home ready for winter! ❄️\n\nIf you have animations disabled in your device accessibility settings, this feature will not work.",
|
||||
"disable_confirmation": "Snowflakes will no longer fall on your screen. You can re-enable this at any time in Labs settings.",
|
||||
|
||||
@@ -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
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:gate"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:gate-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioghost"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioghost==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -20,13 +20,5 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:toggle-switch"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"turned_off": {
|
||||
"trigger": "mdi:toggle-switch-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:toggle-switch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -37,12 +37,11 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
ip_address = user_input[CONF_HOST]
|
||||
guid: str | None = None
|
||||
|
||||
main_repeater = Lutron(
|
||||
ip_address,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
user_input.get(CONF_USERNAME),
|
||||
user_input.get(CONF_PASSWORD),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -56,11 +55,10 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
guid = main_repeater.guid
|
||||
|
||||
if guid is None or len(guid) <= 10:
|
||||
if len(guid) <= 10:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
if not errors:
|
||||
assert guid is not None
|
||||
await self.async_set_unique_id(guid)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class LutronCover(LutronDevice, CoverEntity):
|
||||
"""Update the state attributes."""
|
||||
level = self._lutron_device.last_level()
|
||||
self._attr_is_closed = level < 1
|
||||
self._attr_current_cover_position = int(level)
|
||||
self._attr_current_cover_position = level
|
||||
_LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level)
|
||||
|
||||
@property
|
||||
|
||||
@@ -43,8 +43,10 @@ class LutronBaseEntity(Entity):
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
device_uuid = self._lutron_device.uuid or self._lutron_device.legacy_uuid
|
||||
return f"{self._controller.guid}_{device_uuid}"
|
||||
|
||||
if self._lutron_device.uuid is None:
|
||||
return f"{self._controller.guid}_{self._lutron_device.legacy_uuid}"
|
||||
return f"{self._controller.guid}_{self._lutron_device.uuid}"
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the entity's state."""
|
||||
@@ -81,9 +83,8 @@ class LutronKeypad(LutronBaseEntity):
|
||||
) -> None:
|
||||
"""Initialize the device."""
|
||||
super().__init__(area_name, lutron_device, controller)
|
||||
device_uuid = keypad.uuid or keypad.legacy_uuid
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{controller.guid}_{device_uuid}")},
|
||||
identifiers={(DOMAIN, keypad.id)},
|
||||
manufacturer="Lutron",
|
||||
name=keypad.name,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Support for Lutron events."""
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import cast
|
||||
|
||||
from pylutron import Button, Keypad, Lutron, LutronEntity, LutronEvent
|
||||
from pylutron import Button, Keypad, Lutron, LutronEvent
|
||||
|
||||
from homeassistant.components.event import EventEntity
|
||||
from homeassistant.const import ATTR_ID
|
||||
@@ -79,10 +78,9 @@ class LutronEventEntity(LutronKeypad, EventEntity):
|
||||
|
||||
@callback
|
||||
def handle_event(
|
||||
self, button: LutronEntity, _context: None, event: LutronEvent, _params: dict
|
||||
self, button: Button, _context: None, event: LutronEvent, _params: dict
|
||||
) -> None:
|
||||
"""Handle received event."""
|
||||
button = cast(Button, button)
|
||||
action: LutronEventType | None = None
|
||||
if self._has_release_event:
|
||||
if event == Button.Event.PRESSED:
|
||||
|
||||
@@ -83,7 +83,7 @@ class LutronFan(LutronDevice, FanEntity):
|
||||
|
||||
def _update_attrs(self) -> None:
|
||||
"""Update the state attributes."""
|
||||
level = int(self._lutron_device.last_level())
|
||||
level = self._lutron_device.last_level()
|
||||
self._attr_is_on = level > 0
|
||||
self._attr_percentage = level
|
||||
if self._prev_percentage is None or level != 0:
|
||||
|
||||
@@ -45,12 +45,12 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def to_lutron_level(level: int) -> float:
|
||||
def to_lutron_level(level):
|
||||
"""Convert the given Home Assistant light level (0-255) to Lutron (0.0-100.0)."""
|
||||
return float((level * 100) / 255)
|
||||
|
||||
|
||||
def to_hass_level(level: float) -> int:
|
||||
def to_hass_level(level):
|
||||
"""Convert the given Lutron (0.0-100.0) light level to Home Assistant (0-255)."""
|
||||
return int((level * 255) / 100)
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.3.0"],
|
||||
"requirements": ["pylutron==0.2.18"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the LED on."""
|
||||
self._lutron_device.state = True
|
||||
self._lutron_device.state = 1
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the LED off."""
|
||||
self._lutron_device.state = False
|
||||
self._lutron_device.state = 0
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
|
||||
@@ -124,7 +124,6 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = {
|
||||
# support fan-only mode.
|
||||
(0x0001, 0x0108),
|
||||
(0x0001, 0x010A),
|
||||
(0x118C, 0x2022),
|
||||
(0x1209, 0x8000),
|
||||
(0x1209, 0x8001),
|
||||
(0x1209, 0x8002),
|
||||
|
||||
@@ -378,46 +378,6 @@ DISCOVERY_SCHEMAS = [
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
|
||||
# HoldTime is shared by PIR-specific numbers as a required attribute.
|
||||
# Keep discovery open so this generic schema does not block them.
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="OccupancySensingPIRUnoccupiedToOccupiedDelay",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="detection_delay",
|
||||
native_max_value=65534,
|
||||
native_min_value=0,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay,
|
||||
clusters.OccupancySensing.Attributes.HoldTime, # This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present
|
||||
),
|
||||
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterNumberEntityDescription(
|
||||
key="OccupancySensingPIRUnoccupiedToOccupiedThreshold",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="detection_threshold",
|
||||
native_max_value=254,
|
||||
native_min_value=1,
|
||||
mode=NumberMode.BOX,
|
||||
),
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(
|
||||
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold,
|
||||
clusters.OccupancySensing.Attributes.HoldTime,
|
||||
),
|
||||
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
|
||||
@@ -214,12 +214,6 @@
|
||||
"cook_time": {
|
||||
"name": "Cooking time"
|
||||
},
|
||||
"detection_delay": {
|
||||
"name": "Detection delay"
|
||||
},
|
||||
"detection_threshold": {
|
||||
"name": "Detection threshold"
|
||||
},
|
||||
"hold_time": {
|
||||
"name": "Hold time"
|
||||
},
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aionut import AIONUTClient, NUTError
|
||||
from aionut import AIONUTClient, NUTError, NUTLoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ALIAS,
|
||||
CONF_HOST,
|
||||
@@ -19,17 +21,29 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
|
||||
from .coordinator import NutConfigEntry, NutCoordinator, NutRuntimeData
|
||||
|
||||
NUT_FAKE_SERIAL = ["unknown", "blank"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type NutConfigEntry = ConfigEntry[NutRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutRuntimeData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
coordinator: DataUpdateCoordinator
|
||||
data: PyNUTData
|
||||
unique_id: str
|
||||
user_available_commands: set[str]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
"""Set up Network UPS Tools (NUT) from a config entry."""
|
||||
@@ -59,7 +73,36 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
|
||||
|
||||
entry.async_on_unload(data.async_shutdown)
|
||||
|
||||
coordinator = NutCoordinator(hass, data, entry)
|
||||
async def async_update_data() -> dict[str, str]:
|
||||
"""Fetch data from NUT."""
|
||||
try:
|
||||
return await data.async_update()
|
||||
except NUTLoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_authentication",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
except NUTError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_fetch_error",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name="NUT resource status",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=60),
|
||||
always_update=False,
|
||||
)
|
||||
|
||||
# Fetch initial data so we have data when entities subscribe
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.components.button import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NutConfigEntry
|
||||
from . import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""The NUT coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aionut import NUTError, NUTLoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import PyNUTData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutRuntimeData:
|
||||
"""Runtime data definition."""
|
||||
|
||||
coordinator: NutCoordinator
|
||||
data: PyNUTData
|
||||
unique_id: str
|
||||
user_available_commands: set[str]
|
||||
|
||||
|
||||
type NutConfigEntry = ConfigEntry[NutRuntimeData]
|
||||
|
||||
|
||||
class NutCoordinator(DataUpdateCoordinator[dict[str, str]]):
|
||||
"""Coordinator for NUT data."""
|
||||
|
||||
config_entry: NutConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
data: PyNUTData,
|
||||
config_entry: NutConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize NUT coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name="NUT resource status",
|
||||
update_interval=timedelta(seconds=60),
|
||||
always_update=False,
|
||||
)
|
||||
self._data = data
|
||||
|
||||
async def _async_update_data(self) -> dict[str, str]:
|
||||
"""Fetch data from NUT."""
|
||||
try:
|
||||
return await self._data.async_update()
|
||||
except NUTLoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_authentication",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
except NUTError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="data_fetch_error",
|
||||
translation_placeholders={
|
||||
"err": str(err),
|
||||
},
|
||||
) from err
|
||||
@@ -13,8 +13,8 @@ from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from . import NutConfigEntry, NutRuntimeData
|
||||
from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS
|
||||
from .coordinator import NutConfigEntry, NutRuntimeData
|
||||
|
||||
ACTION_TYPES = {cmd.replace(".", "_") for cmd in INTEGRATION_SUPPORTED_COMMANDS}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import NutConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NutConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_PASSWORD, CONF_USERNAME}
|
||||
|
||||
|
||||
@@ -13,11 +13,13 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import PyNUTData
|
||||
from .const import DOMAIN
|
||||
from .coordinator import NutCoordinator
|
||||
|
||||
NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
|
||||
"manufacturer": ATTR_MANUFACTURER,
|
||||
@@ -27,14 +29,14 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
class NUTBaseEntity(CoordinatorEntity[NutCoordinator]):
|
||||
class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
|
||||
"""NUT base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NutCoordinator,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
data: PyNUTData,
|
||||
unique_id: str,
|
||||
|
||||
@@ -25,8 +25,8 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import NutConfigEntry
|
||||
from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
|
||||
from .coordinator import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import NutConfigEntry
|
||||
from . import NutConfigEntry
|
||||
from .entity import NUTBaseEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.1.6"]
|
||||
"requirements": ["onedrive-personal-sdk==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.1.6"]
|
||||
"requirements": ["onedrive-personal-sdk==0.1.5"]
|
||||
}
|
||||
|
||||
@@ -90,5 +90,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.0.2"]
|
||||
"requirements": ["PSNAWP==3.0.1", "pyrate-limiter==3.9.0"]
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
self.properties_api.smart_wash_params,
|
||||
self.properties_api.sound_volume,
|
||||
self.properties_api.child_lock,
|
||||
self.properties_api.dust_collection_mode,
|
||||
self.properties_api.flow_led_status,
|
||||
self.properties_api.valley_electricity_timer,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==4.20.0",
|
||||
"python-roborock==4.17.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class SmartThingsButtonDescription(ButtonEntityDescription):
|
||||
|
||||
key: Capability
|
||||
command: Command
|
||||
components: list[str] | None = None
|
||||
component: str = MAIN
|
||||
|
||||
|
||||
CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = {
|
||||
@@ -48,7 +48,7 @@ CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] =
|
||||
translation_key="reset_hepa_filter",
|
||||
command=Command.RESET_HEPA_FILTER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
components=[MAIN, "station"],
|
||||
component="station",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ async def async_setup_entry(
|
||||
"""Add button entities for a config entry."""
|
||||
entry_data = entry.runtime_data
|
||||
async_add_entities(
|
||||
SmartThingsButtonEntity(entry_data.client, device, description, component)
|
||||
SmartThingsButtonEntity(entry_data.client, device, description)
|
||||
for capability, description in CAPABILITIES_TO_BUTTONS.items()
|
||||
for device in entry_data.devices.values()
|
||||
for component in description.components or [MAIN]
|
||||
if component in device.status and capability in device.status[component]
|
||||
if description.component in device.status
|
||||
and capability in device.status[description.component]
|
||||
)
|
||||
|
||||
|
||||
@@ -79,12 +79,11 @@ class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
|
||||
client: SmartThings,
|
||||
device: FullDevice,
|
||||
entity_description: SmartThingsButtonDescription,
|
||||
component: str,
|
||||
) -> None:
|
||||
"""Initialize the instance."""
|
||||
super().__init__(client, device, set(), component=component)
|
||||
super().__init__(client, device, set(), component=entity_description.component)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{device.device.device_id}_{component}_{entity_description.key}_{entity_description.command}"
|
||||
self._attr_unique_id = f"{device.device.device_id}_{entity_description.component}_{entity_description.key}_{entity_description.command}"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from spotifyaio import SpotifyClient
|
||||
from spotifyaio import Device, SpotifyClient, SpotifyConnectionError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -16,15 +17,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .browse_media import async_browse_media
|
||||
from .const import DOMAIN, SPOTIFY_SCOPES
|
||||
from .coordinator import (
|
||||
SpotifyConfigEntry,
|
||||
SpotifyCoordinator,
|
||||
SpotifyData,
|
||||
SpotifyDeviceCoordinator,
|
||||
)
|
||||
from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES
|
||||
from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
|
||||
from .models import SpotifyData
|
||||
from .util import (
|
||||
is_spotify_media_type,
|
||||
resolve_spotify_media_type,
|
||||
@@ -75,7 +73,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
device_coordinator = SpotifyDeviceCoordinator(hass, entry, spotify)
|
||||
async def _update_devices() -> list[Device]:
|
||||
try:
|
||||
return await spotify.get_devices()
|
||||
except SpotifyConnectionError as err:
|
||||
raise UpdateFailed from err
|
||||
|
||||
device_coordinator: DataUpdateCoordinator[list[Device]] = DataUpdateCoordinator(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=f"{entry.title} Devices",
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(minutes=5),
|
||||
update_method=_update_devices,
|
||||
)
|
||||
await device_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = SpotifyData(coordinator, session, device_coordinator)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from spotifyaio import (
|
||||
ContextType,
|
||||
Device,
|
||||
PlaybackState,
|
||||
Playlist,
|
||||
SpotifyClient,
|
||||
@@ -19,28 +19,21 @@ from spotifyaio import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import SpotifyData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type SpotifyConfigEntry = ConfigEntry[SpotifyData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpotifyData:
|
||||
"""Class to hold Spotify data."""
|
||||
|
||||
coordinator: SpotifyCoordinator
|
||||
session: OAuth2Session
|
||||
devices: SpotifyDeviceCoordinator
|
||||
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
FREE_API_BLOGPOST = (
|
||||
@@ -171,31 +164,3 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]):
|
||||
playlist=self._playlist,
|
||||
dj_playlist=dj_playlist,
|
||||
)
|
||||
|
||||
|
||||
class SpotifyDeviceCoordinator(DataUpdateCoordinator[list[Device]]):
|
||||
"""Class to manage fetching Spotify data."""
|
||||
|
||||
config_entry: SpotifyConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: SpotifyConfigEntry,
|
||||
client: SpotifyClient,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{config_entry.title} Devices",
|
||||
update_interval=timedelta(minutes=5),
|
||||
)
|
||||
self._client = client
|
||||
|
||||
async def _async_update_data(self) -> list[Device]:
|
||||
try:
|
||||
return await self._client.get_devices()
|
||||
except SpotifyConnectionError as err:
|
||||
raise UpdateFailed from err
|
||||
|
||||
@@ -9,6 +9,7 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, Concatenate
|
||||
|
||||
from spotifyaio import (
|
||||
Device,
|
||||
Episode,
|
||||
Item,
|
||||
ItemType,
|
||||
@@ -31,6 +32,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .browse_media import async_browse_media_internal
|
||||
from .const import (
|
||||
@@ -38,11 +40,7 @@ from .const import (
|
||||
MEDIA_TYPE_USER_SAVED_TRACKS,
|
||||
PLAYABLE_MEDIA_TYPES,
|
||||
)
|
||||
from .coordinator import (
|
||||
SpotifyConfigEntry,
|
||||
SpotifyCoordinator,
|
||||
SpotifyDeviceCoordinator,
|
||||
)
|
||||
from .coordinator import SpotifyConfigEntry, SpotifyCoordinator
|
||||
from .entity import SpotifyEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -124,7 +122,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SpotifyCoordinator,
|
||||
device_coordinator: SpotifyDeviceCoordinator,
|
||||
device_coordinator: DataUpdateCoordinator[list[Device]],
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
19
homeassistant/components/spotify/models.py
Normal file
19
homeassistant/components/spotify/models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Models for use in Spotify integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from spotifyaio import Device
|
||||
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .coordinator import SpotifyCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpotifyData:
|
||||
"""Class to hold Spotify data."""
|
||||
|
||||
coordinator: SpotifyCoordinator
|
||||
session: OAuth2Session
|
||||
devices: DataUpdateCoordinator[list[Device]]
|
||||
@@ -1028,14 +1028,12 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) ->
|
||||
read_timeout=read_timeout,
|
||||
media_write_timeout=media_write_timeout,
|
||||
)
|
||||
get_updates_request = HTTPXRequest(proxy=proxy)
|
||||
else:
|
||||
request = HTTPXRequest(
|
||||
connection_pool_size=8,
|
||||
read_timeout=read_timeout,
|
||||
media_write_timeout=media_write_timeout,
|
||||
)
|
||||
get_updates_request = None
|
||||
|
||||
base_url: str = p_config[CONF_API_ENDPOINT]
|
||||
|
||||
@@ -1044,7 +1042,6 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) ->
|
||||
base_url=f"{base_url}/bot",
|
||||
base_file_url=f"{base_url}/file/bot",
|
||||
request=request,
|
||||
get_updates_request=get_updates_request,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ KNOWN_BRANDS: dict[str | None, str] = {
|
||||
"Nanoleaf": "nanoleaf",
|
||||
"OpenThread": "openthread",
|
||||
"Samsung": "samsung",
|
||||
"SmartThings": "smartthings",
|
||||
}
|
||||
THREAD_TYPE = "_meshcop._udp.local."
|
||||
CLASS_IN = 1
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
"""Intents for the vacuum integration."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import area_registry as ar, config_validation as cv, intent
|
||||
from homeassistant.helpers import intent
|
||||
|
||||
from . import (
|
||||
DOMAIN,
|
||||
SERVICE_CLEAN_AREA,
|
||||
SERVICE_RETURN_TO_BASE,
|
||||
SERVICE_START,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature
|
||||
|
||||
INTENT_VACUUM_START = "HassVacuumStart"
|
||||
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
|
||||
INTENT_VACUUM_CLEAN_AREA = "HassVacuumCleanArea"
|
||||
|
||||
|
||||
async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
@@ -48,156 +35,3 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
|
||||
required_features=VacuumEntityFeature.RETURN_HOME,
|
||||
),
|
||||
)
|
||||
intent.async_register(hass, CleanAreaIntentHandler())
|
||||
|
||||
|
||||
class CleanAreaIntentHandler(intent.IntentHandler):
|
||||
"""Intent handler for cleaning a specific area with a vacuum.
|
||||
|
||||
The area slot is used as a service parameter (cleaning_area_id),
|
||||
not for entity matching.
|
||||
"""
|
||||
|
||||
intent_type = INTENT_VACUUM_CLEAN_AREA
|
||||
platforms = {DOMAIN}
|
||||
description = "Tells a vacuum to clean a specific area"
|
||||
|
||||
@property
|
||||
def slot_schema(self) -> dict:
|
||||
"""Return a slot schema."""
|
||||
return {
|
||||
vol.Required("area"): cv.string,
|
||||
vol.Optional("name"): cv.string,
|
||||
vol.Optional("preferred_area_id"): cv.string,
|
||||
vol.Optional("preferred_floor_id"): cv.string,
|
||||
}
|
||||
|
||||
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
|
||||
"""Handle the intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
# Resolve the area name to an area ID
|
||||
area_name = slots["area"]["value"]
|
||||
area_reg = ar.async_get(hass)
|
||||
matched_areas = list(intent.find_areas(area_name, area_reg))
|
||||
if not matched_areas:
|
||||
raise intent.MatchFailedError(
|
||||
result=intent.MatchTargetsResult(
|
||||
is_match=False,
|
||||
no_match_reason=intent.MatchFailedReason.INVALID_AREA,
|
||||
no_match_name=area_name,
|
||||
),
|
||||
constraints=intent.MatchTargetsConstraints(
|
||||
area_name=area_name,
|
||||
),
|
||||
)
|
||||
|
||||
# Use preferred area/floor from conversation context to disambiguate
|
||||
preferred_area_id = slots.get("preferred_area_id", {}).get("value")
|
||||
preferred_floor_id = slots.get("preferred_floor_id", {}).get("value")
|
||||
if len(matched_areas) > 1 and preferred_area_id is not None:
|
||||
filtered = [a for a in matched_areas if a.id == preferred_area_id]
|
||||
if filtered:
|
||||
matched_areas = filtered
|
||||
if len(matched_areas) > 1 and preferred_floor_id is not None:
|
||||
filtered = [a for a in matched_areas if a.floor_id == preferred_floor_id]
|
||||
if filtered:
|
||||
matched_areas = filtered
|
||||
|
||||
# Match vacuum entity by name
|
||||
name_slot = slots.get("name", {})
|
||||
entity_name: str | None = name_slot.get("value")
|
||||
|
||||
match_constraints = intent.MatchTargetsConstraints(
|
||||
name=entity_name,
|
||||
domains={DOMAIN},
|
||||
features=VacuumEntityFeature.CLEAN_AREA,
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
|
||||
# Use the resolved cleaning area and its floor as preferences
|
||||
# for entity disambiguation
|
||||
target_area = matched_areas[0]
|
||||
match_preferences = intent.MatchTargetsPreferences(
|
||||
area_id=target_area.id,
|
||||
floor_id=target_area.floor_id,
|
||||
)
|
||||
|
||||
match_result = intent.async_match_targets(
|
||||
hass, match_constraints, match_preferences
|
||||
)
|
||||
if not match_result.is_match:
|
||||
raise intent.MatchFailedError(
|
||||
result=match_result,
|
||||
constraints=match_constraints,
|
||||
preferences=match_preferences,
|
||||
)
|
||||
|
||||
# Update intent slots to include any transformations done by the schemas
|
||||
intent_obj.slots = slots
|
||||
|
||||
return await self._async_handle_service(intent_obj, match_result, matched_areas)
|
||||
|
||||
async def _async_handle_service(
|
||||
self,
|
||||
intent_obj: intent.Intent,
|
||||
match_result: intent.MatchTargetsResult,
|
||||
matched_areas: list[ar.AreaEntry],
|
||||
) -> intent.IntentResponse:
|
||||
"""Call clean_area for all matched areas."""
|
||||
hass = intent_obj.hass
|
||||
states = match_result.states
|
||||
|
||||
entity_ids = [state.entity_id for state in states]
|
||||
area_ids = [area.id for area in matched_areas]
|
||||
|
||||
try:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_CLEAN_AREA,
|
||||
{
|
||||
"entity_id": entity_ids,
|
||||
"cleaning_area_id": area_ids,
|
||||
},
|
||||
context=intent_obj.context,
|
||||
blocking=True,
|
||||
)
|
||||
except Exception:
|
||||
_LOGGER.exception(
|
||||
"Failed to call %s for areas: %s with vacuums: %s",
|
||||
SERVICE_CLEAN_AREA,
|
||||
area_ids,
|
||||
entity_ids,
|
||||
)
|
||||
raise intent.IntentHandleError(
|
||||
f"Failed to call {SERVICE_CLEAN_AREA} for areas: {area_ids}"
|
||||
f" with vacuums: {entity_ids}"
|
||||
) from None
|
||||
|
||||
success_results: list[intent.IntentResponseTarget] = [
|
||||
intent.IntentResponseTarget(
|
||||
type=intent.IntentResponseTargetType.AREA,
|
||||
name=area.name,
|
||||
id=area.id,
|
||||
)
|
||||
for area in matched_areas
|
||||
]
|
||||
success_results.extend(
|
||||
intent.IntentResponseTarget(
|
||||
type=intent.IntentResponseTargetType.ENTITY,
|
||||
name=state.name,
|
||||
id=state.entity_id,
|
||||
)
|
||||
for state in states
|
||||
)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
|
||||
response.async_set_results(success_results)
|
||||
|
||||
# Update all states
|
||||
states = [hass.states.get(state.entity_id) or state for state in states]
|
||||
response.async_set_states(states)
|
||||
|
||||
return response
|
||||
|
||||
@@ -721,26 +721,6 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ViCareSensorEntityDescription(
|
||||
key="energy_consumption_heating_this_year",
|
||||
translation_key="energy_consumption_heating_this_year",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_getter=lambda api: api.getPowerConsumptionHeatingThisYear(),
|
||||
unit_getter=lambda api: api.getPowerConsumptionHeatingUnit(),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ViCareSensorEntityDescription(
|
||||
key="energy_consumption_dhw_this_year",
|
||||
translation_key="energy_consumption_dhw_this_year",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
value_getter=lambda api: api.getPowerConsumptionDomesticHotWaterThisYear(),
|
||||
unit_getter=lambda api: api.getPowerConsumptionDomesticHotWaterUnit(),
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ViCareSensorEntityDescription(
|
||||
key="buffer top temperature",
|
||||
translation_key="buffer_top_temperature",
|
||||
|
||||
@@ -289,12 +289,6 @@
|
||||
"energy_consumption_cooling_today": {
|
||||
"name": "Cooling electricity consumption today"
|
||||
},
|
||||
"energy_consumption_dhw_this_year": {
|
||||
"name": "DHW energy consumption this year"
|
||||
},
|
||||
"energy_consumption_heating_this_year": {
|
||||
"name": "Heating energy consumption this year"
|
||||
},
|
||||
"energy_dhw_summary_consumption_heating_currentday": {
|
||||
"name": "DHW electricity consumption today"
|
||||
},
|
||||
|
||||
@@ -35,9 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
|
||||
try:
|
||||
await auth.do_auth(store=False)
|
||||
except (ClientError, TimeoutError) as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN, translation_key="cannot_connect"
|
||||
) from ex
|
||||
raise ConfigEntryNotReady("Cannot connect") from ex
|
||||
except WhirlpoolAccountLocked as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="account_locked"
|
||||
@@ -45,9 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
|
||||
|
||||
if not auth.is_access_token_valid():
|
||||
_LOGGER.error("Authentication failed")
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
)
|
||||
raise ConfigEntryAuthFailed("Incorrect Password")
|
||||
|
||||
appliances_manager = AppliancesManager(backend_selector, auth, session)
|
||||
if not await appliances_manager.fetch_appliances():
|
||||
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
comment: The "unknown" state should not be part of the enum for the dispense level sensor.
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations:
|
||||
status: todo
|
||||
comment: |
|
||||
|
||||
@@ -216,12 +216,6 @@
|
||||
"appliances_fetch_failed": {
|
||||
"message": "Failed to fetch appliances"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "[%key:common::config_flow::error::invalid_auth%]"
|
||||
},
|
||||
"invalid_value_set": {
|
||||
"message": "Invalid value provided"
|
||||
},
|
||||
|
||||
@@ -22,7 +22,6 @@ from homeassistant.core import State, callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.group import IntegrationSpecificGroup
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
@@ -52,18 +51,6 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
|
||||
meta = self.entity_data.entity.info_object
|
||||
self._attr_unique_id = meta.unique_id
|
||||
|
||||
if self.entity_data.is_group_entity:
|
||||
group_proxy = self.entity_data.group_proxy
|
||||
assert group_proxy is not None
|
||||
platform = self.entity_data.entity.PLATFORM
|
||||
unique_ids = [
|
||||
entity.info_object.unique_id
|
||||
for member in group_proxy.group.members
|
||||
for entity in member.associated_entities
|
||||
if platform == entity.PLATFORM
|
||||
]
|
||||
self.group = IntegrationSpecificGroup(self, unique_ids)
|
||||
|
||||
if meta.entity_category is not None:
|
||||
self._attr_entity_category = EntityCategory(meta.entity_category)
|
||||
|
||||
|
||||
@@ -332,9 +332,6 @@ ATTR_NAME: Final = "name"
|
||||
# Contains one string or a list of strings, each being an entity id
|
||||
ATTR_ENTITY_ID: Final = "entity_id"
|
||||
|
||||
# Contains a list of entity ids that are members of a group
|
||||
ATTR_GROUP_ENTITIES: Final = "group_entities"
|
||||
|
||||
# Contains one string, the config entry ID
|
||||
ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id"
|
||||
|
||||
|
||||
5
homeassistant/generated/labs.py
generated
5
homeassistant/generated/labs.py
generated
@@ -19,6 +19,11 @@ LABS_PREVIEW_FEATURES = {
|
||||
},
|
||||
},
|
||||
"frontend": {
|
||||
"windows_98": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
"report_issue_url": "",
|
||||
},
|
||||
"winter_mode": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_PICTURE,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_GROUP_ENTITIES,
|
||||
ATTR_ICON,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
@@ -55,15 +54,13 @@ from homeassistant.loader import async_suggest_report_issue, bind_hass
|
||||
from homeassistant.util import ensure_unique_string, slugify
|
||||
from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed
|
||||
|
||||
from . import device_registry as dr, entity_registry as er
|
||||
from . import device_registry as dr, entity_registry as er, singleton
|
||||
from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData
|
||||
from .event import (
|
||||
async_track_device_registry_updated_event,
|
||||
async_track_entity_registry_updated_event,
|
||||
)
|
||||
from .frame import report_non_thread_safe_operation, report_usage
|
||||
from .group import Group
|
||||
from .singleton import singleton
|
||||
from .frame import report_non_thread_safe_operation
|
||||
from .typing import UNDEFINED, StateType, UndefinedType
|
||||
|
||||
timer = time.time
|
||||
@@ -93,7 +90,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
@singleton(DATA_ENTITY_SOURCE)
|
||||
@singleton.singleton(DATA_ENTITY_SOURCE)
|
||||
def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]:
|
||||
"""Get the entity sources.
|
||||
|
||||
@@ -460,15 +457,6 @@ class Entity(
|
||||
# Only handled internally, never to be used by integrations.
|
||||
internal_integration_suggested_object_id: str | None
|
||||
|
||||
# A group information in case the entity represents a group
|
||||
group: Group | None = None
|
||||
# Internal copy of `group`. This prevents integration authors from
|
||||
# mistakenly overwriting it during the entity's lifetime, which would
|
||||
# break Group functionality. It also lets us check if `group` is
|
||||
# actually a Group instance just once in `async_internal_added_to_hass`,
|
||||
# rather than on every state write.
|
||||
__group: Group | None = None
|
||||
|
||||
# If we reported if this entity was slow
|
||||
_slow_reported = False
|
||||
|
||||
@@ -1076,10 +1064,6 @@ class Entity(
|
||||
entry = self.registry_entry
|
||||
|
||||
capability_attr = self.capability_attributes
|
||||
if self.__group is not None:
|
||||
capability_attr = capability_attr.copy() if capability_attr else {}
|
||||
capability_attr[ATTR_GROUP_ENTITIES] = self.__group.member_entity_ids.copy()
|
||||
|
||||
attr = capability_attr.copy() if capability_attr else {}
|
||||
|
||||
available = self.available # only call self.available once per update cycle
|
||||
@@ -1519,17 +1503,6 @@ class Entity(
|
||||
)
|
||||
self._async_subscribe_device_updates()
|
||||
|
||||
if self.group is not None:
|
||||
if not isinstance(self.group, Group):
|
||||
report_usage( # type: ignore[unreachable]
|
||||
f"sets a `group` attribute on entity {self.entity_id} which is "
|
||||
"not a `Group` instance",
|
||||
breaks_in_ha_version="2027.2",
|
||||
)
|
||||
else:
|
||||
self.__group = self.group
|
||||
self.__group.async_added_to_hass()
|
||||
|
||||
async def async_internal_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity will be removed from hass.
|
||||
|
||||
@@ -1540,9 +1513,6 @@ class Entity(
|
||||
if self.platform:
|
||||
del entity_sources(self.hass)[self.entity_id]
|
||||
|
||||
if self.__group is not None:
|
||||
self.__group.async_will_remove_from_hass()
|
||||
|
||||
@callback
|
||||
def _async_registry_updated(
|
||||
self, event: Event[er.EventEntityRegistryUpdatedData]
|
||||
|
||||
@@ -3,167 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from propcache.api import cached_property
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import entity_registry as er
|
||||
from .singleton import singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .entity import Entity
|
||||
|
||||
DATA_GROUP_ENTITIES = "group_entities"
|
||||
ENTITY_PREFIX = "group."
|
||||
|
||||
|
||||
class Group:
|
||||
"""Entity group base class."""
|
||||
|
||||
_entity: Entity
|
||||
|
||||
def __init__(self, entity: Entity) -> None:
|
||||
"""Initialize the group."""
|
||||
self._entity = entity
|
||||
|
||||
@property
|
||||
def member_entity_ids(self) -> list[str]:
|
||||
"""Return the list of member entity IDs."""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def async_added_to_hass(self) -> None:
|
||||
"""Called when the entity is added to hass."""
|
||||
entity = self._entity
|
||||
get_group_entities(entity.hass)[entity.entity_id] = entity
|
||||
|
||||
@callback
|
||||
def async_will_remove_from_hass(self) -> None:
|
||||
"""Called when the entity will be removed from hass."""
|
||||
entity = self._entity
|
||||
del get_group_entities(entity.hass)[entity.entity_id]
|
||||
|
||||
|
||||
class GenericGroup(Group):
|
||||
"""Generic entity group.
|
||||
|
||||
Members can come from multiple integrations and are referenced by entity ID.
|
||||
"""
|
||||
|
||||
def __init__(self, entity: Entity, member_entity_ids: list[str]) -> None:
|
||||
"""Initialize the group."""
|
||||
super().__init__(entity)
|
||||
self._member_entity_ids = member_entity_ids
|
||||
|
||||
@cached_property
|
||||
def member_entity_ids(self) -> list[str]:
|
||||
"""Return the list of member entity IDs."""
|
||||
return self._member_entity_ids
|
||||
|
||||
|
||||
class IntegrationSpecificGroup(Group):
|
||||
"""Integration-specific entity group.
|
||||
|
||||
Members come from a single integration and are referenced by unique ID.
|
||||
Entity IDs are resolved via the entity registry. This group listens for
|
||||
entity registry events to keep the resolved entity IDs up to date.
|
||||
"""
|
||||
|
||||
_member_entity_ids: list[str] | None = None
|
||||
_member_unique_ids: list[str]
|
||||
|
||||
def __init__(self, entity: Entity, member_unique_ids: list[str]) -> None:
|
||||
"""Initialize the group."""
|
||||
super().__init__(entity)
|
||||
self._member_unique_ids = member_unique_ids
|
||||
|
||||
@cached_property
|
||||
def member_entity_ids(self) -> list[str]:
|
||||
"""Return the list of member entity IDs."""
|
||||
entity_registry = er.async_get(self._entity.hass)
|
||||
self._member_entity_ids = [
|
||||
entity_id
|
||||
for unique_id in self.member_unique_ids
|
||||
if (
|
||||
entity_id := entity_registry.async_get_entity_id(
|
||||
self._entity.platform.domain,
|
||||
self._entity.platform.platform_name,
|
||||
unique_id,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
return self._member_entity_ids
|
||||
|
||||
@property
|
||||
def member_unique_ids(self) -> list[str]:
|
||||
"""Return the list of member unique IDs."""
|
||||
return self._member_unique_ids
|
||||
|
||||
@member_unique_ids.setter
|
||||
def member_unique_ids(self, value: list[str]) -> None:
|
||||
"""Set the list of member unique IDs."""
|
||||
self._member_unique_ids = value
|
||||
if self._member_entity_ids is not None:
|
||||
self._member_entity_ids = None
|
||||
del self.member_entity_ids
|
||||
|
||||
@callback
|
||||
def async_added_to_hass(self) -> None:
|
||||
"""Called when the entity is added to hass."""
|
||||
super().async_added_to_hass()
|
||||
|
||||
entity = self._entity
|
||||
entity_registry = er.async_get(entity.hass)
|
||||
|
||||
@callback
|
||||
def _handle_entity_registry_updated(event: Event[Any]) -> None:
|
||||
"""Handle registry create or update event."""
|
||||
if (
|
||||
event.data["action"] in {"create", "update"}
|
||||
and (entry := entity_registry.async_get(event.data["entity_id"]))
|
||||
and entry.domain == entity.platform.domain
|
||||
and entry.platform == entity.platform.platform_name
|
||||
and entry.unique_id in self.member_unique_ids
|
||||
) or (
|
||||
event.data["action"] == "remove"
|
||||
and self._member_entity_ids is not None
|
||||
and event.data["entity_id"] in self._member_entity_ids
|
||||
):
|
||||
if self._member_entity_ids is not None:
|
||||
self._member_entity_ids = None
|
||||
del self.member_entity_ids
|
||||
entity.async_write_ha_state()
|
||||
|
||||
entity.async_on_remove(
|
||||
entity.hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
_handle_entity_registry_updated,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@singleton(DATA_GROUP_ENTITIES)
|
||||
def get_group_entities(hass: HomeAssistant) -> dict[str, Entity]:
|
||||
"""Get the group entities.
|
||||
|
||||
Items are added to this dict by Group.async_added_to_hass and
|
||||
removed by Group.async_will_remove_from_hass.
|
||||
"""
|
||||
return {}
|
||||
|
||||
|
||||
def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]:
|
||||
"""Return entity_ids with group entity ids replaced by their members.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
group_entities = get_group_entities(hass)
|
||||
|
||||
found_ids: list[str] = []
|
||||
for entity_id in entity_ids:
|
||||
if not isinstance(entity_id, str) or entity_id in (
|
||||
@@ -173,22 +25,8 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st
|
||||
continue
|
||||
|
||||
entity_id = entity_id.lower()
|
||||
|
||||
# If entity_id points at a group, expand it
|
||||
if (entity := group_entities.get(entity_id)) is not None and isinstance(
|
||||
entity.group, GenericGroup
|
||||
):
|
||||
child_entities = entity.group.member_entity_ids
|
||||
if entity_id in child_entities:
|
||||
child_entities = list(child_entities)
|
||||
child_entities.remove(entity_id)
|
||||
found_ids.extend(
|
||||
ent_id
|
||||
for ent_id in expand_entity_ids(hass, child_entities)
|
||||
if ent_id not in found_ids
|
||||
)
|
||||
# If entity_id points at an old-style group, expand it
|
||||
elif entity_id.startswith(ENTITY_PREFIX):
|
||||
if entity_id.startswith(ENTITY_PREFIX):
|
||||
child_entities = get_entity_ids(hass, entity_id)
|
||||
if entity_id in child_entities:
|
||||
child_entities = list(child_entities)
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -3176,16 +3176,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.lutron.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.madvr.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -28,7 +28,7 @@ HueBLE==2.1.0
|
||||
Mastodon.py==2.1.2
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
PSNAWP==3.0.3
|
||||
PSNAWP==3.0.1
|
||||
|
||||
# homeassistant.components.doods
|
||||
# homeassistant.components.generic
|
||||
@@ -1676,7 +1676,7 @@ ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
# homeassistant.components.onedrive_for_business
|
||||
onedrive-personal-sdk==0.1.6
|
||||
onedrive-personal-sdk==0.1.5
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -2245,7 +2245,7 @@ pylitterbot==2025.1.0
|
||||
pylutron-caseta==0.27.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.3.0
|
||||
pylutron==0.2.18
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -2409,7 +2409,7 @@ pyrail==0.4.1
|
||||
pyrainbird==6.1.1
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==4.0.2
|
||||
pyrate-limiter==3.9.0
|
||||
|
||||
# homeassistant.components.recswitch
|
||||
pyrecswitch==1.0.2
|
||||
@@ -2639,7 +2639,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.20.0
|
||||
python-roborock==4.17.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -28,7 +28,7 @@ HueBLE==2.1.0
|
||||
Mastodon.py==2.1.2
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
PSNAWP==3.0.3
|
||||
PSNAWP==3.0.1
|
||||
|
||||
# homeassistant.components.doods
|
||||
# homeassistant.components.generic
|
||||
@@ -1462,7 +1462,7 @@ ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
# homeassistant.components.onedrive_for_business
|
||||
onedrive-personal-sdk==0.1.6
|
||||
onedrive-personal-sdk==0.1.5
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1919,7 +1919,7 @@ pylitterbot==2025.1.0
|
||||
pylutron-caseta==0.27.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.3.0
|
||||
pylutron==0.2.18
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -2059,7 +2059,7 @@ pyrail==0.4.1
|
||||
pyrainbird==6.1.1
|
||||
|
||||
# homeassistant.components.playstation_network
|
||||
pyrate-limiter==4.0.2
|
||||
pyrate-limiter==3.9.0
|
||||
|
||||
# homeassistant.components.risco
|
||||
pyrisco==0.6.7
|
||||
@@ -2235,7 +2235,7 @@ python-pooldose==0.8.2
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==4.20.0
|
||||
python-roborock==4.17.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.47
|
||||
|
||||
@@ -77,7 +77,6 @@ NO_IOT_CLASS = [
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -2112,7 +2112,6 @@ NO_QUALITY_SCALE = [
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"gate",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyanglianwater.api import API
|
||||
from pyanglianwater.meter import SmartMeter
|
||||
import pytest
|
||||
@@ -33,20 +32,20 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_smart_meter(freezer: FrozenDateTimeFactory) -> SmartMeter:
|
||||
"""Return a Smart Meter for testing."""
|
||||
# Freeze time to June 2, 2024 so "yesterday" is June 1, matching our test readings
|
||||
freezer.move_to("2024-06-02T00:00:00Z")
|
||||
|
||||
meter = SmartMeter("TESTSN")
|
||||
meter.readings = [
|
||||
def mock_smart_meter() -> SmartMeter:
|
||||
"""Return a mocked Smart Meter."""
|
||||
mock = AsyncMock(spec=SmartMeter)
|
||||
mock.serial_number = "TESTSN"
|
||||
mock.get_yesterday_consumption = 50
|
||||
mock.latest_read = 50
|
||||
mock.yesterday_water_cost = 0.5
|
||||
mock.yesterday_sewerage_cost = 0.5
|
||||
mock.readings = [
|
||||
{"read_at": "2024-06-01T12:00:00Z", "consumption": 10, "read": 10},
|
||||
{"read_at": "2024-06-01T13:00:00Z", "consumption": 15, "read": 25},
|
||||
{"read_at": "2024-06-01T14:00:00Z", "consumption": 25, "read": 50},
|
||||
]
|
||||
meter.yesterday_water_cost = 0.5
|
||||
meter.yesterday_sewerage_cost = 0.5
|
||||
return meter
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,54 +1,4 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensor[sensor.testsn_last_meter_reading_processed-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.testsn_last_meter_reading_processed',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Last meter reading processed',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Last meter reading processed',
|
||||
'platform': 'anglian_water',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': <AnglianWaterSensor.LAST_UPDATED: 'last_updated'>,
|
||||
'unique_id': 'TESTSN_last_updated',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.testsn_last_meter_reading_processed-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'friendly_name': 'TESTSN Last meter reading processed',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.testsn_last_meter_reading_processed',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '2024-06-01T14:00:00+00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.testsn_latest_reading-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -103,7 +53,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '50.0',
|
||||
'state': '50',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.testsn_yesterday_s_sewerage_cost-entry]
|
||||
@@ -211,7 +161,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '50.0',
|
||||
'state': '50',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[sensor.testsn_yesterday_s_water_cost-entry]
|
||||
|
||||
@@ -8,11 +8,13 @@ from arcam.fmj.state import State
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.arcam_fmj.const import DEFAULT_NAME
|
||||
from homeassistant.components.arcam_fmj.media_player import ArcamFmj
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity import EntityPlatformState
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, MockEntityPlatform
|
||||
|
||||
MOCK_HOST = "127.0.0.1"
|
||||
MOCK_PORT = 50000
|
||||
@@ -45,9 +47,7 @@ def state_1_fixture(client: Mock) -> State:
|
||||
state.get_power.return_value = True
|
||||
state.get_volume.return_value = 0.0
|
||||
state.get_source_list.return_value = []
|
||||
state.get_incoming_audio_format.return_value = (None, None)
|
||||
state.get_incoming_video_parameters.return_value = None
|
||||
state.get_incoming_audio_sample_rate.return_value = 0
|
||||
state.get_incoming_audio_format.return_value = (0, 0)
|
||||
state.get_mute.return_value = None
|
||||
state.get_decode_modes.return_value = []
|
||||
return state
|
||||
@@ -62,36 +62,39 @@ def state_2_fixture(client: Mock) -> State:
|
||||
state.get_power.return_value = True
|
||||
state.get_volume.return_value = 0.0
|
||||
state.get_source_list.return_value = []
|
||||
state.get_incoming_audio_format.return_value = (None, None)
|
||||
state.get_incoming_video_parameters.return_value = None
|
||||
state.get_incoming_audio_sample_rate.return_value = 0
|
||||
state.get_incoming_audio_format.return_value = (0, 0)
|
||||
state.get_mute.return_value = None
|
||||
state.get_decode_modes.return_value = []
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture(name="mock_config_entry")
|
||||
def mock_config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Get a mock config entry."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain="arcam_fmj",
|
||||
data=MOCK_CONFIG_ENTRY,
|
||||
title=MOCK_NAME,
|
||||
unique_id=MOCK_UUID,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
return config_entry
|
||||
@pytest.fixture(name="state")
|
||||
def state_fixture(state_1: State) -> State:
|
||||
"""Get a mocked state."""
|
||||
return state_1
|
||||
|
||||
|
||||
@pytest.fixture(name="player")
|
||||
def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj:
|
||||
"""Get standard player."""
|
||||
player = ArcamFmj(MOCK_NAME, state, MOCK_UUID)
|
||||
player.entity_id = MOCK_ENTITY_ID
|
||||
player.hass = hass
|
||||
player.platform = MockEntityPlatform(hass)
|
||||
player._platform_state = EntityPlatformState.ADDED
|
||||
player.async_write_ha_state = Mock()
|
||||
return player
|
||||
|
||||
|
||||
@pytest.fixture(name="player_setup")
|
||||
async def player_setup_fixture(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
state_1: State,
|
||||
state_2: State,
|
||||
client: Mock,
|
||||
hass: HomeAssistant, state_1: State, state_2: State, client: Mock
|
||||
) -> AsyncGenerator[str]:
|
||||
"""Get standard player."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain="arcam_fmj", data=MOCK_CONFIG_ENTRY, title=MOCK_NAME
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
def state_mock(cli, zone):
|
||||
if zone == 1:
|
||||
@@ -100,23 +103,16 @@ async def player_setup_fixture(
|
||||
return state_2
|
||||
raise ValueError(f"Unknown player zone: {zone}")
|
||||
|
||||
async def _mock_run_client(hass: HomeAssistant, runtime_data, interval):
|
||||
for coordinator in runtime_data.coordinators.values():
|
||||
coordinator.async_notify_connected()
|
||||
|
||||
await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.arcam_fmj.Client", return_value=client),
|
||||
patch(
|
||||
"homeassistant.components.arcam_fmj.coordinator.State",
|
||||
"homeassistant.components.arcam_fmj.media_player.State",
|
||||
side_effect=state_mock,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.arcam_fmj._run_client",
|
||||
side_effect=_mock_run_client,
|
||||
),
|
||||
patch("homeassistant.components.arcam_fmj._run_client", return_value=None),
|
||||
):
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
yield MOCK_ENTITY_ID
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""The tests for Arcam FMJ Receiver control device triggers."""
|
||||
|
||||
from arcam.fmj.state import State
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.arcam_fmj.const import DOMAIN
|
||||
from homeassistant.components.device_automation import DeviceAutomationType
|
||||
@@ -56,12 +54,12 @@ async def test_if_fires_on_turn_on_request(
|
||||
entity_registry: er.EntityRegistry,
|
||||
service_calls: list[ServiceCall],
|
||||
player_setup,
|
||||
state_1: State,
|
||||
state,
|
||||
) -> None:
|
||||
"""Test for turn_on and turn_off triggers firing."""
|
||||
entry = entity_registry.async_get(player_setup)
|
||||
|
||||
state_1.get_power.return_value = None
|
||||
state.get_power.return_value = None
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
@@ -106,12 +104,12 @@ async def test_if_fires_on_turn_on_request_legacy(
|
||||
entity_registry: er.EntityRegistry,
|
||||
service_calls: list[ServiceCall],
|
||||
player_setup,
|
||||
state_1: State,
|
||||
state,
|
||||
) -> None:
|
||||
"""Test for turn_on and turn_off triggers firing."""
|
||||
entry = entity_registry.async_get(player_setup)
|
||||
|
||||
state_1.get_power.return_value = None
|
||||
state.get_power.return_value = None
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user