Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Bottein
df06a5878c Add windows 98 labs feature 2026-03-10 09:41:49 +01:00
152 changed files with 856 additions and 6190 deletions

View File

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

View File

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

View File

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

2
CODEOWNERS generated
View File

@@ -577,8 +577,6 @@ build.json @home-assistant/supervisor
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001

View File

@@ -243,7 +243,6 @@ DEFAULT_INTEGRATIONS = {
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import logging
from typing import Any
from arcam.fmj import ConnectionFailed, SourceCodes
from arcam.fmj.state import State
from homeassistant.components.media_player import (
BrowseError,
@@ -19,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."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,11 +87,11 @@ class LutronLed(LutronKeypad, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the LED on."""
self._lutron_device.state = True
self._lutron_device.state = 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:

View File

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

View File

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

View File

@@ -214,12 +214,6 @@
"cook_time": {
"name": "Cooking time"
},
"detection_delay": {
"name": "Detection delay"
},
"detection_threshold": {
"name": "Detection threshold"
},
"hold_time": {
"name": "Hold time"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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]]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -77,7 +77,6 @@ NO_IOT_CLASS = [
"file_upload",
"frontend",
"garage_door",
"gate",
"hardkernel",
"hardware",
"history",

View File

@@ -2112,7 +2112,6 @@ NO_QUALITY_SCALE = [
"file_upload",
"frontend",
"garage_door",
"gate",
"hardkernel",
"hardware",
"history",

View File

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

View File

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

View File

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

View File

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