Compare commits

..

1 Commits

Author SHA1 Message Date
G Johansson bda95f4d7a Create preview for history_stats 2025-01-07 22:24:05 +00:00
207 changed files with 9356 additions and 11127 deletions
-1
View File
@@ -291,7 +291,6 @@ homeassistant.components.lcn.*
homeassistant.components.ld2410_ble.*
homeassistant.components.led_ble.*
homeassistant.components.lektrico.*
homeassistant.components.letpot.*
homeassistant.components.lidarr.*
homeassistant.components.lifx.*
homeassistant.components.light.*
-2
View File
@@ -831,8 +831,6 @@ build.json @home-assistant/supervisor
/tests/components/led_ble/ @bdraco
/homeassistant/components/lektrico/ @lektrico
/tests/components/lektrico/ @lektrico
/homeassistant/components/letpot/ @jpelgrom
/tests/components/letpot/ @jpelgrom
/homeassistant/components/lg_netcast/ @Drafteed @splinter98
/tests/components/lg_netcast/ @Drafteed @splinter98
/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration
@@ -67,21 +67,18 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"humidity": SensorEntityDescription(
key="humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"pressure": SensorEntityDescription(
key="pressure",
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.MBAR,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
"battery": SensorEntityDescription(
key="battery",
@@ -89,28 +86,24 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
),
"co2": SensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"voc": SensorEntityDescription(
key="voc",
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"illuminance": SensorEntityDescription(
key="illuminance",
translation_key="illuminance",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
@@ -7,7 +7,6 @@ from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
from enum import StrEnum
import random
from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
@@ -29,10 +28,6 @@ if TYPE_CHECKING:
CRON_PATTERN_DAILY = "45 4 * * *"
CRON_PATTERN_WEEKLY = "45 4 * * {}"
# Randomize the start time of the backup by up to 60 minutes to avoid
# all backups running at the same time.
BACKUP_START_TIME_JITTER = 60 * 60
class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
@@ -334,8 +329,6 @@ class BackupSchedule:
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected error creating automatic backup")
next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER))
LOGGER.debug("Scheduling next automatic backup at %s", next_time)
manager.remove_next_backup_event = async_track_point_in_time(
manager.hass, _create_backup, next_time
)
+10 -14
View File
@@ -14,13 +14,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import BluesoundCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.MEDIA_PLAYER,
]
PLATFORMS = [Platform.MEDIA_PLAYER]
@dataclass
@@ -29,7 +26,6 @@ class BluesoundRuntimeData:
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
@@ -37,6 +33,9 @@ type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = []
return True
@@ -47,16 +46,13 @@ async def async_setup_entry(
host = config_entry.data[CONF_HOST]
port = config_entry.data[CONF_PORT]
session = async_get_clientsession(hass)
player = Player(host, port, session=session, default_timeout=10)
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
async with Player(host, port, session=session, default_timeout=10) as player:
try:
sync_status = await player.sync_status(timeout=1)
except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
coordinator = BluesoundCoordinator(hass, player, sync_status)
await coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator)
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -1,160 +0,0 @@
"""Define a base coordinator for Bluesound entities."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass, replace
from datetime import timedelta
import logging
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3)
PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15)
@dataclass
class BluesoundData:
"""Define a class to hold Bluesound data."""
sync_status: SyncStatus
status: Status
presets: list[Preset]
inputs: list[Input]
def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
"""Cancel a task."""
async def _cancel_task() -> None:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return _cancel_task
class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
"""Define an object to hold Bluesound data."""
def __init__(
self, hass: HomeAssistant, player: Player, sync_status: SyncStatus
) -> None:
"""Initialize."""
self.player = player
self._inital_sync_status = sync_status
super().__init__(
hass,
logger=_LOGGER,
name=sync_status.name,
)
async def _async_setup(self) -> None:
assert self.config_entry is not None
preset = await self.player.presets()
inputs = await self.player.inputs()
status = await self.player.status()
self.async_set_updated_data(
BluesoundData(
sync_status=self._inital_sync_status,
status=status,
presets=preset,
inputs=inputs,
)
)
status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(status_loop_task))
sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
name=f"bluesound.poll_sync_status_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(sync_status_loop_task))
presets_and_inputs_loop_task = self.hass.async_create_background_task(
self._poll_presets_and_inputs_loop(),
name=f"bluesound.poll_presets_and_inputs_loop_{self.data.sync_status.id}",
)
self.config_entry.async_on_unload(cancel_task(presets_and_inputs_loop_task))
async def _async_update_data(self) -> BluesoundData:
return self.data
async def _poll_presets_and_inputs_loop(self) -> None:
while True:
await asyncio.sleep(PRESET_AND_INPUTS_INTERVAL.total_seconds())
try:
preset = await self.player.presets()
inputs = await self.player.inputs()
self.async_set_updated_data(
replace(
self.data,
presets=preset,
inputs=inputs,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
status = await self.player.status(
etag=self.data.status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
status=status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
return
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
sync_status = await self.player.sync_status(
etag=self.data.sync_status.etag, poll_timeout=120, timeout=125
)
self.async_set_updated_data(
replace(
self.data,
sync_status=sync_status,
)
)
except PlayerUnreachableError as ex:
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
except asyncio.CancelledError:
raise
except Exception as ex: # noqa: BLE001 - this loop should never stop
self.async_set_update_error(ex)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT.total_seconds())
@@ -2,12 +2,15 @@
from __future__ import annotations
from asyncio import Task
import asyncio
from asyncio import CancelledError, Task
from contextlib import suppress
from datetime import datetime, timedelta
import logging
from typing import TYPE_CHECKING, Any
from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError
import voluptuous as vol
from homeassistant.components import media_source
@@ -20,7 +23,7 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import (
@@ -33,11 +36,9 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
import homeassistant.util.dt as dt_util
from .const import ATTR_BLUESOUND_GROUP, ATTR_MASTER, DOMAIN
from .coordinator import BluesoundCoordinator
from .utils import dispatcher_join_signal, dispatcher_unjoin_signal, format_unique_id
if TYPE_CHECKING:
@@ -55,6 +56,11 @@ SERVICE_JOIN = "join"
SERVICE_SET_TIMER = "set_sleep_timer"
SERVICE_UNJOIN = "unjoin"
NODE_OFFLINE_CHECK_TIMEOUT = 180
NODE_RETRY_INITIATION = timedelta(minutes=3)
SYNC_STATUS_INTERVAL = timedelta(minutes=5)
POLL_TIMEOUT = 120
@@ -65,10 +71,10 @@ async def async_setup_entry(
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
config_entry.runtime_data.coordinator,
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.runtime_data.player,
config_entry.runtime_data.sync_status,
)
platform = entity_platform.async_get_current_platform()
@@ -83,10 +89,11 @@ async def async_setup_entry(
)
platform.async_register_entity_service(SERVICE_UNJOIN, None, "async_unjoin")
hass.data[DATA_BLUESOUND].append(bluesound_player)
async_add_entities([bluesound_player], update_before_add=True)
class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity):
class BluesoundPlayer(MediaPlayerEntity):
"""Representation of a Bluesound Player."""
_attr_media_content_type = MediaType.MUSIC
@@ -95,15 +102,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
def __init__(
self,
coordinator: BluesoundCoordinator,
host: str,
port: int,
player: Player,
sync_status: SyncStatus,
) -> None:
"""Initialize the media player."""
super().__init__(coordinator)
sync_status = coordinator.data.sync_status
self.host = host
self.port = port
self._poll_status_loop_task: Task[None] | None = None
@@ -111,14 +115,15 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
self._id = sync_status.id
self._last_status_update: datetime | None = None
self._sync_status = sync_status
self._status: Status = coordinator.data.status
self._inputs: list[Input] = coordinator.data.inputs
self._presets: list[Preset] = coordinator.data.presets
self._status: Status | None = None
self._inputs: list[Input] = []
self._presets: list[Preset] = []
self._group_name: str | None = None
self._group_list: list[str] = []
self._bluesound_device_name = sync_status.name
self._player = player
self._last_status_update = dt_util.utcnow()
self._is_leader = False
self._leader: BluesoundPlayer | None = None
self._attr_unique_id = format_unique_id(sync_status.mac, port)
# there should always be one player with the default port per mac
@@ -141,10 +146,52 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
via_device=(DOMAIN, format_mac(sync_status.mac)),
)
async def _poll_status_loop(self) -> None:
"""Loop which polls the status of the player."""
while True:
try:
await self.async_update_status()
except PlayerUnreachableError:
_LOGGER.error(
"Node %s:%s is offline, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
_LOGGER.debug(
"Stopping the polling of node %s:%s", self.host, self.port
)
return
except: # noqa: E722 - this loop should never stop
_LOGGER.exception(
"Unexpected error for %s:%s, retrying later", self.host, self.port
)
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def _poll_sync_status_loop(self) -> None:
"""Loop which polls the sync status of the player."""
while True:
try:
await self.update_sync_status()
except PlayerUnreachableError:
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
except CancelledError:
raise
except: # noqa: E722 - all errors must be caught for this loop
await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT)
async def async_added_to_hass(self) -> None:
"""Start the polling task."""
await super().async_added_to_hass()
self._poll_status_loop_task = self.hass.async_create_background_task(
self._poll_status_loop(),
name=f"bluesound.poll_status_loop_{self.host}:{self.port}",
)
self._poll_sync_status_loop_task = self.hass.async_create_background_task(
self._poll_sync_status_loop(),
name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}",
)
assert self._sync_status.id is not None
self.async_on_remove(
async_dispatcher_connect(
@@ -165,24 +212,105 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
"""Stop the polling task."""
await super().async_will_remove_from_hass()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._sync_status = self.coordinator.data.sync_status
self._status = self.coordinator.data.status
self._inputs = self.coordinator.data.inputs
self._presets = self.coordinator.data.presets
assert self._poll_status_loop_task is not None
if self._poll_status_loop_task.cancel():
# the sleeps in _poll_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_status_loop_task
self._last_status_update = dt_util.utcnow()
assert self._poll_sync_status_loop_task is not None
if self._poll_sync_status_loop_task.cancel():
# the sleeps in _poll_sync_status_loop will raise CancelledError
with suppress(CancelledError):
await self._poll_sync_status_loop_task
self.hass.data[DATA_BLUESOUND].remove(self)
async def async_update(self) -> None:
"""Update internal status of the entity."""
if not self.available:
return
with suppress(PlayerUnreachableError):
await self.async_update_presets()
await self.async_update_captures()
async def async_update_status(self) -> None:
"""Use the poll session to always get the status of the player."""
etag = None
if self._status is not None:
etag = self._status.etag
try:
status = await self._player.status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._attr_available = True
self._last_status_update = dt_util.utcnow()
self._status = status
self.async_write_ha_state()
except PlayerUnreachableError:
self._attr_available = False
self._last_status_update = None
self._status = None
self.async_write_ha_state()
_LOGGER.error(
"Client connection error, marking %s as offline",
self._bluesound_device_name,
)
raise
async def update_sync_status(self) -> None:
"""Update the internal status."""
etag = None
if self._sync_status:
etag = self._sync_status.etag
sync_status = await self._player.sync_status(
etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5
)
self._sync_status = sync_status
self._group_list = self.rebuild_bluesound_group()
if sync_status.leader is not None:
self._is_leader = False
leader_id = f"{sync_status.leader.ip}:{sync_status.leader.port}"
leader_device = [
device
for device in self.hass.data[DATA_BLUESOUND]
if device.id == leader_id
]
if leader_device and leader_id != self.id:
self._leader = leader_device[0]
else:
self._leader = None
_LOGGER.error("Leader not found %s", leader_id)
else:
if self._leader is not None:
self._leader = None
followers = self._sync_status.followers
self._is_leader = followers is not None
self.async_write_ha_state()
async def async_update_captures(self) -> None:
"""Update Capture sources."""
inputs = await self._player.inputs()
self._inputs = inputs
async def async_update_presets(self) -> None:
"""Update Presets."""
presets = await self._player.presets()
self._presets = presets
@property
def state(self) -> MediaPlayerState:
"""Return the state of the device."""
if self.available is False:
if self._status is None:
return MediaPlayerState.OFF
if self.is_grouped and not self.is_leader:
@@ -199,7 +327,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_title(self) -> str | None:
"""Title of current playing media."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.name
@@ -207,7 +335,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_artist(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self.available is False:
if self._status is None:
return None
if self.is_grouped and not self.is_leader:
@@ -218,7 +346,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_album_name(self) -> str | None:
"""Artist of current playing media (Music track only)."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
return self._status.album
@@ -226,7 +354,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_image_url(self) -> str | None:
"""Image url of current playing media."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
url = self._status.image
@@ -241,7 +369,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_position(self) -> int | None:
"""Position of current playing media in seconds."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
mediastate = self.state
@@ -260,7 +388,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def media_duration(self) -> int | None:
"""Duration of current playing media in seconds."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
duration = self._status.total_seconds
@@ -277,11 +405,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def volume_level(self) -> float | None:
"""Volume level of the media player (0..1)."""
volume = self._status.volume
volume = None
if self._status is not None:
volume = self._status.volume
if self.is_grouped:
volume = self._sync_status.volume
if volume is None:
return None
return volume / 100
@property
@@ -314,7 +447,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def source_list(self) -> list[str] | None:
"""List of available input sources."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
sources = [x.text for x in self._inputs]
@@ -325,7 +458,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def source(self) -> str | None:
"""Name of the current input source."""
if self.available is False or (self.is_grouped and not self.is_leader):
if self._status is None or (self.is_grouped and not self.is_leader):
return None
if self._status.input_id is not None:
@@ -342,7 +475,7 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag of media commands that are supported."""
if self.available is False:
if self._status is None:
return MediaPlayerEntityFeature(0)
if self.is_grouped and not self.is_leader:
@@ -444,21 +577,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self.sync_status.leader is None and self.sync_status.followers is None:
return []
config_entries: list[BluesoundConfigEntry] = (
self.hass.config_entries.async_entries(DOMAIN)
)
sync_status_list = [
x.runtime_data.coordinator.data.sync_status for x in config_entries
]
player_entities: list[BluesoundPlayer] = self.hass.data[DATA_BLUESOUND]
leader_sync_status: SyncStatus | None = None
if self.sync_status.leader is None:
leader_sync_status = self.sync_status
else:
required_id = f"{self.sync_status.leader.ip}:{self.sync_status.leader.port}"
for sync_status in sync_status_list:
if sync_status.id == required_id:
leader_sync_status = sync_status
for x in player_entities:
if x.sync_status.id == required_id:
leader_sync_status = x.sync_status
break
if leader_sync_status is None or leader_sync_status.followers is None:
@@ -466,9 +594,9 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
follower_ids = [f"{x.ip}:{x.port}" for x in leader_sync_status.followers]
follower_names = [
sync_status.name
for sync_status in sync_status_list
if sync_status.id in follower_ids
x.sync_status.name
for x in player_entities
if x.sync_status.id in follower_ids
]
follower_names.insert(0, leader_sync_status.name)
return follower_names
@@ -19,7 +19,7 @@
"bluetooth-adapters==0.20.2",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.20.0",
"dbus-fast==2.28.0",
"dbus-fast==2.24.3",
"habluetooth==3.7.0"
]
}
+27 -70
View File
@@ -2,12 +2,10 @@
from __future__ import annotations
import asyncio
import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
import random
from typing import Any
from aiohttp import ClientError, ClientTimeout
@@ -29,9 +27,6 @@ from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
async def _b64md5(stream: AsyncIterator[bytes]) -> str:
@@ -130,44 +125,6 @@ class CloudBackupAgent(BackupAgent):
return ChunkAsyncStreamIterator(resp.content)
async def _async_do_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
filename: str,
base64md5hash: str,
metadata: dict[str, Any],
size: int,
) -> None:
"""Upload a backup."""
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=filename,
metadata=metadata,
size=size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_upload_backup(
self,
*,
@@ -184,34 +141,34 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Cloud backups must be protected")
base64md5hash = await _b64md5(await open_stream())
filename = self._get_backup_filename()
metadata = backup.as_dict()
size = backup.size
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._async_do_upload_backup(
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
metadata=metadata,
size=size,
)
break
except BackupAgentError as err:
if tries == _RETRY_LIMIT:
raise
tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info(
"Failed to upload backup, retrying (%s/%s) in %ss: %s",
tries,
_RETRY_LIMIT,
retry_timer,
err,
)
await asyncio.sleep(retry_timer)
try:
details = await async_files_upload_details(
self._cloud,
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
metadata=backup.as_dict(),
size=backup.size,
base64md5hash=base64md5hash,
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get upload details") from err
try:
upload_status = await self._cloud.websession.put(
details["url"],
data=await open_stream(),
headers=details["headers"] | {"content-length": str(backup.size)},
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
_LOGGER.log(
logging.DEBUG if upload_status.status < 400 else logging.WARNING,
"Backup upload status: %s",
upload_status.status,
)
upload_status.raise_for_status()
except (TimeoutError, ClientError) as err:
raise BackupAgentError("Failed to upload backup") from err
async def async_delete_backup(
self,
+23 -64
View File
@@ -2,29 +2,41 @@
from __future__ import annotations
import logging
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
from cookidoo_api import CookidooAuthException, CookidooRequestException
from homeassistant.const import Platform
from homeassistant.const import (
CONF_COUNTRY,
CONF_EMAIL,
CONF_LANGUAGE,
CONF_PASSWORD,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
from .helpers import cookidoo_from_config_entry
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.TODO]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
"""Set up Cookidoo from a config entry."""
coordinator = CookidooDataUpdateCoordinator(
hass, await cookidoo_from_config_entry(hass, entry), entry
localizations = await get_localization_options(
country=entry.data[CONF_COUNTRY].lower(),
language=entry.data[CONF_LANGUAGE],
)
cookidoo = Cookidoo(
async_get_clientsession(hass),
CookidooConfig(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
localization=localizations[0],
),
)
coordinator = CookidooDataUpdateCoordinator(hass, cookidoo, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -37,56 +49,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: CookidooConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: CookidooConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: CookidooConfigEntry
) -> bool:
"""Migrate config entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1 and config_entry.minor_version == 1:
# Add the unique uuid
cookidoo = await cookidoo_from_config_entry(hass, config_entry)
try:
auth_data = await cookidoo.login()
except (CookidooRequestException, CookidooAuthException) as e:
_LOGGER.error(
"Could not migrate config config_entry: %s",
str(e),
)
return False
unique_id = auth_data.sub
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry_id=config_entry.entry_id
)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for dev in device_entries:
device_registry.async_update_device(
dev.id, new_identifiers={(DOMAIN, unique_id)}
)
for ent in entity_entries:
assert ent.config_entry_id
entity_registry.async_update_entity(
ent.entity_id,
new_unique_id=ent.unique_id.replace(ent.config_entry_id, unique_id),
)
hass.config_entries.async_update_entry(
config_entry, unique_id=auth_data.sub, minor_version=2
)
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True
+1 -2
View File
@@ -56,8 +56,7 @@ class CookidooButton(CookidooBaseEntity, ButtonEntity):
"""Initialize cookidoo button."""
super().__init__(coordinator)
self.entity_description = description
assert coordinator.config_entry.unique_id
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
async def async_press(self) -> None:
"""Press the button."""
@@ -7,7 +7,9 @@ import logging
from typing import Any
from cookidoo_api import (
Cookidoo,
CookidooAuthException,
CookidooConfig,
CookidooRequestException,
get_country_options,
get_localization_options,
@@ -21,6 +23,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
)
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
@@ -32,7 +35,6 @@ from homeassistant.helpers.selector import (
)
from .const import DOMAIN
from .helpers import cookidoo_from_config_data
_LOGGER = logging.getLogger(__name__)
@@ -55,14 +57,10 @@ AUTH_DATA_SCHEMA = {
class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cookidoo."""
VERSION = 1
MINOR_VERSION = 2
COUNTRY_DATA_SCHEMA: dict
LANGUAGE_DATA_SCHEMA: dict
user_input: dict[str, Any]
user_uuid: str
async def async_step_reconfigure(
self, user_input: dict[str, Any]
@@ -80,11 +78,8 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None and not (
errors := await self.validate_input(user_input)
):
await self.async_set_unique_id(self.user_uuid)
if self.source == SOURCE_USER:
self._abort_if_unique_id_configured()
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
self._async_abort_entries_match({CONF_EMAIL: user_input[CONF_EMAIL]})
self.user_input = user_input
return await self.async_step_language()
await self.generate_country_schema()
@@ -158,8 +153,10 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
if not (
errors := await self.validate_input({**reauth_entry.data, **user_input})
):
await self.async_set_unique_id(self.user_uuid)
self._abort_if_unique_id_mismatch()
if user_input[CONF_EMAIL] != reauth_entry.data[CONF_EMAIL]:
self._async_abort_entries_match(
{CONF_EMAIL: user_input[CONF_EMAIL]}
)
return self.async_update_reload_and_abort(
reauth_entry, data_updates=user_input
)
@@ -223,10 +220,21 @@ class CookidooConfigFlow(ConfigFlow, domain=DOMAIN):
await get_localization_options(country=data_input[CONF_COUNTRY].lower())
)[0].language # Pick any language to test login
cookidoo = await cookidoo_from_config_data(self.hass, data_input)
localizations = await get_localization_options(
country=data_input[CONF_COUNTRY].lower(),
language=data_input[CONF_LANGUAGE],
)
cookidoo = Cookidoo(
async_get_clientsession(self.hass),
CookidooConfig(
email=data_input[CONF_EMAIL],
password=data_input[CONF_PASSWORD],
localization=localizations[0],
),
)
try:
auth_data = await cookidoo.login()
self.user_uuid = auth_data.sub
await cookidoo.login()
if language_input:
await cookidoo.get_additional_items()
except CookidooRequestException:
+1 -3
View File
@@ -21,12 +21,10 @@ class CookidooBaseEntity(CoordinatorEntity[CookidooDataUpdateCoordinator]):
"""Initialize the entity."""
super().__init__(coordinator)
assert coordinator.config_entry.unique_id
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name="Cookidoo",
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Vorwerk International & Co. KmG",
model="Cookidoo - Thermomix® recipe portal",
)
@@ -1,37 +0,0 @@
"""Helpers for cookidoo."""
from typing import Any
from cookidoo_api import Cookidoo, CookidooConfig, get_localization_options
from homeassistant.const import CONF_COUNTRY, CONF_EMAIL, CONF_LANGUAGE, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import CookidooConfigEntry
async def cookidoo_from_config_data(
hass: HomeAssistant, data: dict[str, Any]
) -> Cookidoo:
"""Build cookidoo from config data."""
localizations = await get_localization_options(
country=data[CONF_COUNTRY].lower(),
language=data[CONF_LANGUAGE],
)
return Cookidoo(
async_get_clientsession(hass),
CookidooConfig(
email=data[CONF_EMAIL],
password=data[CONF_PASSWORD],
localization=localizations[0],
),
)
async def cookidoo_from_config_entry(
hass: HomeAssistant, entry: CookidooConfigEntry
) -> Cookidoo:
"""Build cookidoo from config entry."""
return await cookidoo_from_config_data(hass, dict(entry.data))
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["cookidoo_api"],
"quality_scale": "silver",
"requirements": ["cookidoo-api==0.12.2"]
"requirements": ["cookidoo-api==0.11.2"]
}
@@ -44,8 +44,7 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The user identifier does not match the previous identifier"
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
+2 -4
View File
@@ -52,8 +52,7 @@ class CookidooIngredientsTodoListEntity(CookidooBaseEntity, TodoListEntity):
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
assert coordinator.config_entry.unique_id
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_ingredients"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_ingredients"
@property
def todo_items(self) -> list[TodoItem]:
@@ -113,8 +112,7 @@ class CookidooAdditionalItemTodoListEntity(CookidooBaseEntity, TodoListEntity):
def __init__(self, coordinator: CookidooDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
assert coordinator.config_entry.unique_id
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_additional_items"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_additional_items"
@property
def todo_items(self) -> list[TodoItem]:
@@ -19,7 +19,7 @@ rules:
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: |
@@ -41,7 +41,7 @@ rules:
status: exempt
comment: |
The integration does not provide any additional options.
docs-installation-parameters: done
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
+13 -20
View File
@@ -20,7 +20,6 @@ from dsmr_parser.objects import DSMRObject, MbusDevice, Telegram
import serial
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -457,29 +456,23 @@ def rename_old_gas_to_mbus(
if entity.unique_id.endswith(
"belgium_5min_gas_meter_reading"
) or entity.unique_id.endswith("hourly_gas_meter_reading"):
if ent_reg.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, mbus_device_id
):
try:
ent_reg.async_update_entity(
entity.entity_id,
new_unique_id=mbus_device_id,
)
except ValueError:
LOGGER.debug(
"Skip migration of %s because it already exists",
entity.entity_id,
)
continue
new_device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, mbus_device_id)},
)
ent_reg.async_update_entity(
entity.entity_id,
new_unique_id=mbus_device_id,
device_id=new_device.id,
)
LOGGER.debug(
"Migrated entity %s from unique id %s to %s",
entity.entity_id,
entity.unique_id,
mbus_device_id,
)
else:
LOGGER.debug(
"Migrated entity %s from unique id %s to %s",
entity.entity_id,
entity.unique_id,
mbus_device_id,
)
# Cleanup old device
dev_entities = er.async_entries_for_device(
ent_reg, device_id, include_disabled_entities=True
@@ -8,7 +8,11 @@ rules:
comment: fixed 1 minute cycle based on Enphase Envoy device characteristics
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow-test-coverage:
status: todo
comment: |
- test_zero_conf_malformed_serial_property - with pytest.raises(KeyError) as ex::
I don't believe this should be able to raise a KeyError Shouldn't we abort the flow?
config-flow:
status: todo
comment: |
@@ -56,7 +60,11 @@ rules:
status: done
comment: pending https://github.com/home-assistant/core/pull/132373
reauthentication-flow: done
test-coverage: done
test-coverage:
status: todo
comment: |
- test_config_different_unique_id -> unique_id set to the mock config entry is an int, not a str
- Apart from the coverage, test_option_change_reload does not verify that the config entry is reloaded
# Gold
devices: done
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyflick"],
"requirements": ["PyFlick==1.1.3"]
"requirements": ["PyFlick==1.1.2"]
}
@@ -51,19 +51,19 @@ class FlickPricingSensor(CoordinatorEntity[FlickElectricDataCoordinator], Sensor
_LOGGER.warning(
"Unexpected quantity for unit price: %s", self.coordinator.data
)
return self.coordinator.data.cost * 100
return self.coordinator.data.cost
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
components: dict[str, float] = {}
components: dict[str, Decimal] = {}
for component in self.coordinator.data.components:
if component.charge_setter not in ATTR_COMPONENTS:
_LOGGER.warning("Found unknown component: %s", component.charge_setter)
continue
components[component.charge_setter] = float(component.value * 100)
components[component.charge_setter] = component.value
return {
ATTR_START_AT: self.coordinator.data.start_at,
@@ -15,7 +15,7 @@ from homeassistant.helpers import aiohttp_client
from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU
from .coordinator import FGLairCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.CLIMATE]
type FGLairConfigEntry = ConfigEntry[FGLairCoordinator]
@@ -25,11 +25,13 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FGLairConfigEntry
from .const import DOMAIN
from .coordinator import FGLairCoordinator
from .entity import FGLairEntity
HA_TO_FUJI_FAN = {
FAN_LOW: FanSpeed.LOW,
@@ -70,19 +72,28 @@ async def async_setup_entry(
)
class FGLairDevice(FGLairEntity, ClimateEntity):
class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity):
"""Represent a Fujitsu HVAC device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_precision = PRECISION_HALVES
_attr_target_temperature_step = 0.5
_attr_has_entity_name = True
_attr_name = None
def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
"""Store the representation of the device and set the static attributes."""
super().__init__(coordinator, device)
super().__init__(coordinator, context=device.device_serial_number)
self._attr_unique_id = device.device_serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_serial_number)},
name=device.device_name,
manufacturer="Fujitsu",
model=device.property_values["model_name"],
serial_number=device.device_serial_number,
sw_version=device.property_values["mcu_firmware_version"],
)
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
@@ -98,6 +109,11 @@ class FGLairDevice(FGLairEntity, ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._set_attr()
@property
def device(self) -> FujitsuHVAC:
"""Return the device object from the coordinator data."""
return self.coordinator.data[self.coordinator_context]
@property
def available(self) -> bool:
"""Return if the device is available."""
@@ -1,33 +0,0 @@
"""Fujitsu FGlair base entity."""
from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FGLairCoordinator
class FGLairEntity(CoordinatorEntity[FGLairCoordinator]):
"""Generic Fglair entity (base class)."""
_attr_has_entity_name = True
def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
"""Store the representation of the device."""
super().__init__(coordinator, context=device.device_serial_number)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.device_serial_number)},
name=device.device_name,
manufacturer="Fujitsu",
model=device.property_values["model_name"],
serial_number=device.device_serial_number,
sw_version=device.property_values["mcu_firmware_version"],
)
@property
def device(self) -> FujitsuHVAC:
"""Return the device object from the coordinator data."""
return self.coordinator.data[self.coordinator_context]
@@ -1,47 +0,0 @@
"""Outside temperature sensor for Fujitsu FGlair HVAC systems."""
from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .climate import FGLairConfigEntry
from .coordinator import FGLairCoordinator
from .entity import FGLairEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FGLairConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up one Fujitsu HVAC device."""
async_add_entities(
FGLairOutsideTemperature(entry.runtime_data, device)
for device in entry.runtime_data.data.values()
)
class FGLairOutsideTemperature(FGLairEntity, SensorEntity):
"""Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "fglair_outside_temp"
def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
"""Store the representation of the device."""
super().__init__(coordinator, device)
self._attr_unique_id = f"{device.device_serial_number}_outside_temperature"
@property
def native_value(self) -> float | None:
"""Return the sensed outdoor temperature un celsius."""
return self.device.outdoor_temperature # type: ignore[no-any-return]
@@ -35,12 +35,5 @@
"cn": "China"
}
}
},
"entity": {
"sensor": {
"fglair_outside_temp": {
"name": "Outside temperature"
}
}
}
}
@@ -24,7 +24,6 @@ from .coordinator import FytaCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
]
type FytaConfigEntry = ConfigEntry[FytaCoordinator]
@@ -1,117 +0,0 @@
"""Binary sensors for Fyta."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Final
from fyta_cli.fyta_models import Plant
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FytaConfigEntry
from .entity import FytaPlantEntity
@dataclass(frozen=True, kw_only=True)
class FytaBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Fyta binary sensor entity."""
value_fn: Callable[[Plant], bool]
BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [
FytaBinarySensorEntityDescription(
key="low_battery",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda plant: plant.low_battery,
),
FytaBinarySensorEntityDescription(
key="notification_light",
translation_key="notification_light",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda plant: plant.notification_light,
),
FytaBinarySensorEntityDescription(
key="notification_nutrition",
translation_key="notification_nutrition",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda plant: plant.notification_nutrition,
),
FytaBinarySensorEntityDescription(
key="notification_temperature",
translation_key="notification_temperature",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda plant: plant.notification_temperature,
),
FytaBinarySensorEntityDescription(
key="notification_water",
translation_key="notification_water",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda plant: plant.notification_water,
),
FytaBinarySensorEntityDescription(
key="sensor_update_available",
device_class=BinarySensorDeviceClass.UPDATE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda plant: plant.sensor_update_available,
),
FytaBinarySensorEntityDescription(
key="productive_plant",
translation_key="productive_plant",
value_fn=lambda plant: plant.productive_plant,
),
FytaBinarySensorEntityDescription(
key="repotted",
translation_key="repotted",
value_fn=lambda plant: plant.repotted,
),
]
async def async_setup_entry(
hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the FYTA binary sensors."""
coordinator = entry.runtime_data
async_add_entities(
FytaPlantBinarySensor(coordinator, entry, sensor, plant_id)
for plant_id in coordinator.fyta.plant_list
for sensor in BINARY_SENSORS
if sensor.key in dir(coordinator.data.get(plant_id))
)
def _async_add_new_device(plant_id: int) -> None:
async_add_entities(
FytaPlantBinarySensor(coordinator, entry, sensor, plant_id)
for sensor in BINARY_SENSORS
if sensor.key in dir(coordinator.data.get(plant_id))
)
coordinator.new_device_callbacks.append(_async_add_new_device)
class FytaPlantBinarySensor(FytaPlantEntity, BinarySensorEntity):
"""Represents a Fyta binary sensor."""
entity_description: FytaBinarySensorEntityDescription
@property
def is_on(self) -> bool:
"""Return value of the binary sensor."""
return self.entity_description.value_fn(self.plant)
+2 -2
View File
@@ -2,8 +2,8 @@
from fyta_cli.fyta_models import Plant
from homeassistant.components.sensor import SensorEntityDescription
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FytaConfigEntry
@@ -20,7 +20,7 @@ class FytaPlantEntity(CoordinatorEntity[FytaCoordinator]):
self,
coordinator: FytaCoordinator,
entry: FytaConfigEntry,
description: EntityDescription,
description: SensorEntityDescription,
plant_id: int,
) -> None:
"""Initialize the Fyta sensor."""
-20
View File
@@ -1,25 +1,5 @@
{
"entity": {
"binary_sensor": {
"notification_light": {
"default": "mdi:lightbulb-alert-outline"
},
"notification_nutrition": {
"default": "mdi:beaker-alert-outline"
},
"notification_temperature": {
"default": "mdi:thermometer-alert"
},
"notification_water": {
"default": "mdi:watering-can-outline"
},
"productive_plant": {
"default": "mdi:fruit-grapes"
},
"repotted": {
"default": "mdi:shovel"
}
},
"sensor": {
"status": {
"default": "mdi:flower"
@@ -38,29 +38,6 @@
}
},
"entity": {
"binary_sensor": {
"notification_light": {
"name": "Light notification"
},
"notification_nutrition": {
"name": "Nutrition notification"
},
"notification_temperature": {
"name": "Temperature notification"
},
"notification_water": {
"name": "Water notification"
},
"productive_plant": {
"name": "Productive plant"
},
"repotted": {
"name": "Repotted"
},
"sensor_update_available": {
"name": "Sensor update available"
}
},
"sensor": {
"scientific_name": {
"name": "Scientific name"
@@ -88,7 +88,6 @@ SUPPORT_LANGUAGES = [
"uk",
"ur",
"vi",
"yue",
# dialects
"zh-CN",
"zh-cn",
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_translate",
"iot_class": "cloud_push",
"loggers": ["gtts"],
"requirements": ["gTTS==2.5.3"]
"requirements": ["gTTS==2.2.4"]
}
+7 -7
View File
@@ -275,11 +275,11 @@ class GroupManager:
player_id_to_entity_id_map = self.entity_id_map
for group in groups.values():
leader_entity_id = player_id_to_entity_id_map.get(group.lead_player_id)
leader_entity_id = player_id_to_entity_id_map.get(group.leader.player_id)
member_entity_ids = [
player_id_to_entity_id_map[member]
for member in group.member_player_ids
if member in player_id_to_entity_id_map
player_id_to_entity_id_map[member.player_id]
for member in group.members
if member.player_id in player_id_to_entity_id_map
]
# Make sure the group leader is always the first element
group_info = [leader_entity_id, *member_entity_ids]
@@ -422,7 +422,7 @@ class SourceManager:
None,
)
if index is not None:
await player.play_preset_station(index)
await player.play_favorite(index)
return
input_source = next(
@@ -434,7 +434,7 @@ class SourceManager:
None,
)
if input_source is not None:
await player.play_input_source(input_source.media_id)
await player.play_input_source(input_source)
return
_LOGGER.error("Unknown source: %s", source)
@@ -447,7 +447,7 @@ class SourceManager:
(
input_source.name
for input_source in self.inputs
if input_source.media_id == now_playing_media.media_id
if input_source.input_name == now_playing_media.media_id
),
None,
)
+1 -1
View File
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/heos",
"iot_class": "local_push",
"loggers": ["pyheos"],
"requirements": ["pyheos==0.9.0"],
"requirements": ["pyheos==0.8.0"],
"single_config_entry": true,
"ssdp": [
{
@@ -47,9 +47,9 @@ BASE_SUPPORTED_FEATURES = (
)
PLAY_STATE_TO_STATE = {
heos_const.PlayState.PLAY: MediaPlayerState.PLAYING,
heos_const.PlayState.STOP: MediaPlayerState.IDLE,
heos_const.PlayState.PAUSE: MediaPlayerState.PAUSED,
heos_const.PLAY_STATE_PLAY: MediaPlayerState.PLAYING,
heos_const.PLAY_STATE_STOP: MediaPlayerState.IDLE,
heos_const.PLAY_STATE_PAUSE: MediaPlayerState.PAUSED,
}
CONTROL_TO_SUPPORT = {
@@ -61,11 +61,11 @@ CONTROL_TO_SUPPORT = {
}
HA_HEOS_ENQUEUE_MAP = {
None: heos_const.AddCriteriaType.REPLACE_AND_PLAY,
MediaPlayerEnqueue.ADD: heos_const.AddCriteriaType.ADD_TO_END,
MediaPlayerEnqueue.REPLACE: heos_const.AddCriteriaType.REPLACE_AND_PLAY,
MediaPlayerEnqueue.NEXT: heos_const.AddCriteriaType.PLAY_NEXT,
MediaPlayerEnqueue.PLAY: heos_const.AddCriteriaType.PLAY_NOW,
None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END,
MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY,
MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT,
MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW,
}
_LOGGER = logging.getLogger(__name__)
@@ -268,7 +268,7 @@ class HeosMediaPlayer(MediaPlayerEntity):
)
if index is None:
raise ValueError(f"Invalid favorite '{media_id}'")
await self._player.play_preset_station(index)
await self._player.play_favorite(index)
return
raise ValueError(f"Unsupported media type '{media_type}'")
@@ -3,11 +3,15 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, cast
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
@@ -25,6 +29,7 @@ from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
)
from homeassistant.helpers.template import Template
from .const import (
CONF_DURATION,
@@ -36,6 +41,9 @@ from .const import (
DEFAULT_NAME,
DOMAIN,
)
from .coordinator import HistoryStatsUpdateCoordinator
from .data import HistoryStats
from .sensor import HistoryStatsSensor
async def validate_options(
@@ -82,12 +90,14 @@ CONFIG_FLOW = {
"options": SchemaFlowFormStep(
schema=DATA_SCHEMA_OPTIONS,
validate_user_input=validate_options,
preview="history_stats",
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
DATA_SCHEMA_OPTIONS,
validate_user_input=validate_options,
preview="history_stats",
),
}
@@ -101,3 +111,88 @@ class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_NAME])
@staticmethod
async def async_setup_preview(hass: HomeAssistant) -> None:
"""Set up preview WS API."""
websocket_api.async_register_command(hass, ws_start_preview)
@websocket_api.websocket_command(
{
vol.Required("type"): "history_stats/start_preview",
vol.Required("flow_id"): str,
vol.Required("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Required("user_input"): dict,
}
)
@websocket_api.async_response
async def ws_start_preview(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Generate a preview."""
if msg["flow_type"] == "config_flow":
flow_status = hass.config_entries.flow.async_get(msg["flow_id"])
flow_sets = hass.config_entries.flow._handler_progress_index.get( # noqa: SLF001
flow_status["handler"]
)
options = {}
assert flow_sets
for active_flow in flow_sets:
options = active_flow._common_handler.options # type: ignore [attr-defined] # noqa: SLF001
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
entity_id = options[CONF_ENTITY_ID]
name = options[CONF_NAME]
sensor_type = options[CONF_TYPE]
else:
flow_status = hass.config_entries.options.async_get(msg["flow_id"])
config_entry = hass.config_entries.async_get_entry(flow_status["handler"])
if not config_entry:
raise HomeAssistantError("Config entry not found")
entity_id = config_entry.options[CONF_ENTITY_ID]
name = config_entry.options[CONF_NAME]
sensor_type = config_entry.options[CONF_TYPE]
@callback
def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None:
"""Forward config entry state events to websocket."""
connection.send_message(
websocket_api.event_message(
msg["id"], {"attributes": attributes, "state": state}
)
)
entity_id = options[CONF_ENTITY_ID]
entity_states: list[str] = options[CONF_STATE]
start: str | None = options.get(CONF_START)
end: str | None = options.get(CONF_END)
duration: timedelta | None = None
if duration_dict := options.get(CONF_DURATION):
duration = timedelta(**duration_dict)
if sum(param in options for param in CONF_PERIOD_KEYS) != 2:
return
history_stats = HistoryStats(
hass,
entity_id,
entity_states,
Template(start, hass) if start else None,
Template(end, hass) if end else None,
duration,
)
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name)
await coordinator.async_refresh()
preview_entity = HistoryStatsSensor(
hass, coordinator, sensor_type, name, None, entity_id
)
preview_entity.hass = hass
connection.send_result(msg["id"])
connection.subscriptions[msg["id"]] = await preview_entity.async_start_preview(
async_preview_updated
)
@@ -3,6 +3,7 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable, Mapping
import datetime
from typing import Any
@@ -23,7 +24,7 @@ from homeassistant.const import (
PERCENTAGE,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
@@ -183,6 +184,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
self._attr_native_unit_of_measurement = UNITS[sensor_type]
self._type = sensor_type
self._attr_unique_id = unique_id
self._source_entity_id = source_entity_id
self._attr_device_info = async_device_info_to_link_from_entity(
hass,
source_entity_id,
@@ -192,6 +194,27 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
self._attr_device_class = SensorDeviceClass.DURATION
self._attr_suggested_display_precision = 2
self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None
async def async_start_preview(
self,
preview_callback: Callable[[str, Mapping[str, Any]], None],
) -> CALLBACK_TYPE:
"""Render a preview."""
# abort early if there is no entity_id
# as without we can't track changes
# or either size or max_age is not set
if not self._source_entity_id:
self._attr_available = False
calculated_state = self._async_calculate_state()
preview_callback(calculated_state.state, calculated_state.attributes)
return self._call_on_remove_callbacks
self._preview_callback = preview_callback
self._process_update()
return self._call_on_remove_callbacks
@callback
def _process_update(self) -> None:
"""Process an update from the coordinator."""
@@ -19,7 +19,6 @@ from homeassistant.core import callback
from homeassistant.helpers.selector import (
CountrySelector,
CountrySelectorConfig,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
@@ -31,30 +30,6 @@ from .const import CONF_CATEGORIES, CONF_PROVINCE, DOMAIN
SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False)
def get_optional_provinces(country: str) -> list[Any]:
"""Return the country provinces (territories).
Some territories can have extra or different holidays
from another within the same country.
Some territories can have different names (aliases).
"""
province_options: list[Any] = []
if provinces := SUPPORTED_COUNTRIES[country]:
country_data = country_holidays(country, years=dt_util.utcnow().year)
if country_data.subdivisions_aliases and (
subdiv_aliases := country_data.get_subdivision_aliases()
):
province_options = [
SelectOptionDict(value=k, label=", ".join(v))
for k, v in subdiv_aliases.items()
]
else:
province_options = provinces
return province_options
def get_optional_categories(country: str) -> list[str]:
"""Return the country categories.
@@ -70,7 +45,7 @@ def get_optional_categories(country: str) -> list[str]:
def get_options_schema(country: str) -> vol.Schema:
"""Return the options schema."""
schema = {}
if provinces := get_optional_provinces(country):
if provinces := SUPPORTED_COUNTRIES[country]:
schema[vol.Optional(CONF_PROVINCE)] = SelectSelector(
SelectSelectorConfig(
options=provinces,
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2025.1.0"]
"requirements": ["aioautomower==2024.12.0"]
}
+9 -35
View File
@@ -4,9 +4,8 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine
from dataclasses import asdict, dataclass
from dataclasses import dataclass
import logging
from typing import Any
from pyipma.api import IPMA_API
from pyipma.location import Location
@@ -29,41 +28,23 @@ _LOGGER = logging.getLogger(__name__)
class IPMASensorEntityDescription(SensorEntityDescription):
"""Describes a IPMA sensor entity."""
value_fn: Callable[
[Location, IPMA_API], Coroutine[Location, IPMA_API, tuple[Any, dict[str, Any]]]
]
value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]]
async def async_retrieve_rcm(
location: Location, api: IPMA_API
) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]:
async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None:
"""Retrieve RCM."""
fire_risk: RCM = await location.fire_risk(api)
if fire_risk:
return fire_risk.rcm, {}
return None, {}
return fire_risk.rcm
return None
async def async_retrieve_uvi(
location: Location, api: IPMA_API
) -> tuple[int, dict[str, Any]] | tuple[None, dict[str, Any]]:
async def async_retrieve_uvi(location: Location, api: IPMA_API) -> int | None:
"""Retrieve UV."""
uv_risk: UV = await location.uv_risk(api)
if uv_risk:
return round(uv_risk.iUv), {}
return None, {}
async def async_retrieve_warning(
location: Location, api: IPMA_API
) -> tuple[Any, dict[str, str]]:
"""Retrieve Warning."""
warnings = await location.warnings(api)
if len(warnings):
return warnings[0].awarenessLevelID, {
k: str(v) for k, v in asdict(warnings[0]).items()
}
return "green", {}
return round(uv_risk.iUv)
return None
SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = (
@@ -77,11 +58,6 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = (
translation_key="uv_index",
value_fn=async_retrieve_uvi,
),
IPMASensorEntityDescription(
key="alert",
translation_key="weather_alert",
value_fn=async_retrieve_warning,
),
)
@@ -118,8 +94,6 @@ class IPMASensor(SensorEntity, IPMADevice):
async def async_update(self) -> None:
"""Update sensors."""
async with asyncio.timeout(10):
state, attrs = await self.entity_description.value_fn(
self._attr_native_value = await self.entity_description.value_fn(
self._location, self._api
)
self._attr_native_value = state
self._attr_extra_state_attributes = attrs
@@ -31,15 +31,6 @@
},
"uv_index": {
"name": "UV index"
},
"weather_alert": {
"name": "Weather Alert",
"state": {
"red": "Red",
"yellow": "Yellow",
"orange": "Orange",
"green": "Green"
}
}
}
}
+1 -3
View File
@@ -91,7 +91,7 @@ from .schema import (
WeatherSchema,
)
from .services import register_knx_services
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
from .storage.config_store import KNXConfigStore
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
from .websocket import register_panel
@@ -226,8 +226,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
if knxkeys_filename is not None:
with contextlib.suppress(FileNotFoundError):
(storage_dir / knxkeys_filename).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / CONFIG_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
(storage_dir / PROJECT_STORAGE_KEY).unlink()
with contextlib.suppress(FileNotFoundError):
@@ -1,94 +0,0 @@
"""The LetPot integration."""
from __future__ import annotations
import asyncio
from letpot.client import LetPotClient
from letpot.converters import CONVERTERS
from letpot.exceptions import LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
)
from .coordinator import LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [Platform.TIME]
type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]
async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
"""Set up LetPot from a config entry."""
auth = AuthenticationInfo(
access_token=entry.data[CONF_ACCESS_TOKEN],
access_token_expires=entry.data[CONF_ACCESS_TOKEN_EXPIRES],
refresh_token=entry.data[CONF_REFRESH_TOKEN],
refresh_token_expires=entry.data[CONF_REFRESH_TOKEN_EXPIRES],
user_id=entry.data[CONF_USER_ID],
email=entry.data[CONF_EMAIL],
)
websession = async_get_clientsession(hass)
client = LetPotClient(websession, auth)
if not auth.is_valid:
try:
auth = await client.refresh_token()
hass.config_entries.async_update_entry(
entry,
data={
CONF_ACCESS_TOKEN: auth.access_token,
CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
CONF_REFRESH_TOKEN: auth.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
CONF_USER_ID: auth.user_id,
CONF_EMAIL: auth.email,
},
)
except LetPotAuthenticationException as exc:
raise ConfigEntryError from exc
try:
devices = await client.get_devices()
except LetPotAuthenticationException as exc:
raise ConfigEntryError from exc
except LetPotException as exc:
raise ConfigEntryNotReady from exc
coordinators: list[LetPotDeviceCoordinator] = [
LetPotDeviceCoordinator(hass, auth, device)
for device in devices
if any(converter.supports_type(device.device_type) for converter in CONVERTERS)
]
await asyncio.gather(
*[
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators
]
)
entry.runtime_data = coordinators
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
for coordinator in entry.runtime_data:
coordinator.device_client.disconnect()
return unload_ok
@@ -1,92 +0,0 @@
"""Config flow for the LetPot integration."""
from __future__ import annotations
import logging
from typing import Any
from letpot.client import LetPotClient
from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import (
CONF_ACCESS_TOKEN_EXPIRES,
CONF_REFRESH_TOKEN,
CONF_REFRESH_TOKEN_EXPIRES,
CONF_USER_ID,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): TextSelector(
TextSelectorConfig(
type=TextSelectorType.EMAIL,
),
),
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
),
),
}
)
class LetPotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for LetPot."""
VERSION = 1
async def _async_validate_credentials(
self, email: str, password: str
) -> dict[str, Any]:
websession = async_get_clientsession(self.hass)
client = LetPotClient(websession)
auth = await client.login(email, password)
return {
CONF_ACCESS_TOKEN: auth.access_token,
CONF_ACCESS_TOKEN_EXPIRES: auth.access_token_expires,
CONF_REFRESH_TOKEN: auth.refresh_token,
CONF_REFRESH_TOKEN_EXPIRES: auth.refresh_token_expires,
CONF_USER_ID: auth.user_id,
CONF_EMAIL: auth.email,
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
try:
data_dict = await self._async_validate_credentials(
user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
)
except LetPotConnectionException:
errors["base"] = "cannot_connect"
except LetPotAuthenticationException:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(data_dict[CONF_USER_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=data_dict[CONF_EMAIL], data=data_dict
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
-10
View File
@@ -1,10 +0,0 @@
"""Constants for the LetPot integration."""
DOMAIN = "letpot"
CONF_ACCESS_TOKEN_EXPIRES = "access_token_expires"
CONF_REFRESH_TOKEN = "refresh_token"
CONF_REFRESH_TOKEN_EXPIRES = "refresh_token_expires"
CONF_USER_ID = "user_id"
REQUEST_UPDATE_TIMEOUT = 10
@@ -1,67 +0,0 @@
"""Coordinator for the LetPot integration."""
from __future__ import annotations
import asyncio
import logging
from typing import TYPE_CHECKING
from letpot.deviceclient import LetPotDeviceClient
from letpot.exceptions import LetPotAuthenticationException, LetPotException
from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import REQUEST_UPDATE_TIMEOUT
if TYPE_CHECKING:
from . import LetPotConfigEntry
_LOGGER = logging.getLogger(__name__)
class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]):
"""Class to handle data updates for a specific garden."""
config_entry: LetPotConfigEntry
device: LetPotDevice
device_client: LetPotDeviceClient
def __init__(
self, hass: HomeAssistant, info: AuthenticationInfo, device: LetPotDevice
) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"LetPot {device.serial_number}",
)
self._info = info
self.device = device
self.device_client = LetPotDeviceClient(info, device.serial_number)
def _handle_status_update(self, status: LetPotDeviceStatus) -> None:
"""Distribute status update to entities."""
self.async_set_updated_data(data=status)
async def _async_setup(self) -> None:
"""Set up subscription for coordinator."""
try:
await self.device_client.subscribe(self._handle_status_update)
except LetPotAuthenticationException as exc:
raise ConfigEntryError from exc
async def _async_update_data(self) -> LetPotDeviceStatus:
"""Request an update from the device and wait for a status update or timeout."""
try:
async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT):
await self.device_client.get_current_status()
except LetPotException as exc:
raise UpdateFailed(exc) from exc
# The subscription task will have updated coordinator.data, so return that data.
# If we don't return anything here, coordinator.data will be set to None.
return self.data
-25
View File
@@ -1,25 +0,0 @@
"""Base class for LetPot entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LetPotDeviceCoordinator
class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
"""Defines a base LetPot entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: LetPotDeviceCoordinator) -> None:
"""Initialize a LetPot entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.device.serial_number)},
name=coordinator.device.name,
manufacturer="LetPot",
model=coordinator.device_client.device_model_name,
model_id=coordinator.device_client.device_model_code,
serial_number=coordinator.device.serial_number,
)
@@ -1,11 +0,0 @@
{
"domain": "letpot",
"name": "LetPot",
"codeowners": ["@jpelgrom"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/letpot",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["letpot==0.2.0"]
}
@@ -1,75 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration only receives push-based updates.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading:
status: done
comment: |
Push connection connects in coordinator _async_setup, disconnects in init async_unload_entry.
docs-configuration-parameters:
status: exempt
comment: |
The integration does not have configuration options.
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -1,34 +0,0 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address of your LetPot account.",
"password": "The password of your LetPot account."
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"entity": {
"time": {
"light_schedule_end": {
"name": "Light off"
},
"light_schedule_start": {
"name": "Light on"
}
}
}
}
-93
View File
@@ -1,93 +0,0 @@
"""Support for LetPot time entities."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import time
from typing import Any
from letpot.deviceclient import LetPotDeviceClient
from letpot.models import LetPotDeviceStatus
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import LetPotConfigEntry
from .coordinator import LetPotDeviceCoordinator
from .entity import LetPotEntity
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LetPotTimeEntityDescription(TimeEntityDescription):
"""Describes a LetPot time entity."""
value_fn: Callable[[LetPotDeviceStatus], time | None]
set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]]
TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
LetPotTimeEntityDescription(
key="light_schedule_end",
translation_key="light_schedule_end",
value_fn=lambda status: None if status is None else status.light_schedule_end,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=None, end=value
),
entity_category=EntityCategory.CONFIG,
),
LetPotTimeEntityDescription(
key="light_schedule_start",
translation_key="light_schedule_start",
value_fn=lambda status: None if status is None else status.light_schedule_start,
set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule(
start=value, end=None
),
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LetPot time entities based on a config entry."""
coordinators = entry.runtime_data
async_add_entities(
LetPotTimeEntity(coordinator, description)
for description in TIME_SENSORS
for coordinator in coordinators
)
class LetPotTimeEntity(LetPotEntity, TimeEntity):
"""Defines a LetPot time entity."""
entity_description: LetPotTimeEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotTimeEntityDescription,
) -> None:
"""Initialize LetPot time entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def native_value(self) -> time | None:
"""Return the time."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_set_value(self, value: time) -> None:
"""Set the time."""
await self.entity_description.set_value_fn(
self.coordinator.device_client, value
)
@@ -99,7 +99,6 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaUpdatableEntity, CoverEntity):
PYLUTRON_TYPE_TO_CLASSES = {
"SerenaTiltOnlyWoodBlind": LutronCasetaTiltOnlyBlind,
"Tilt": LutronCasetaTiltOnlyBlind,
"SerenaHoneycombShade": LutronCasetaShade,
"SerenaRollerShade": LutronCasetaShade,
"TriathlonHoneycombShade": LutronCasetaShade,
@@ -6,7 +6,6 @@ import logging
from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from meteofrance_api.model import CurrentPhenomenons, Forecast, Rain
from requests import RequestException
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -84,13 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_method=_async_update_data_rain,
update_interval=SCAN_INTERVAL_RAIN,
)
try:
await coordinator_rain._async_refresh(log_failures=False) # noqa: SLF001
except RequestException:
_LOGGER.warning(
"1 hour rain forecast not available: %s is not in covered zone",
entry.title,
)
await coordinator_rain.async_config_entry_first_refresh()
department = coordinator_forecast.data.position.get("dept")
_LOGGER.debug(
@@ -135,9 +128,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = {
UNDO_UPDATE_LISTENER: undo_listener,
COORDINATOR_FORECAST: coordinator_forecast,
COORDINATOR_RAIN: coordinator_rain,
}
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert
@@ -187,7 +187,7 @@ async def async_setup_entry(
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST]
coordinator_rain: DataUpdateCoordinator[Rain] | None = data.get(COORDINATOR_RAIN)
coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN]
coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get(
COORDINATOR_ALERT
)
@@ -1,114 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration doesn't provide any service actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow:
status: todo
comment: Check removal and replacement of name in config flow with the title (server address).
config-flow-test-coverage:
status: todo
comment: |
Merge test_show_config_form with full flow test.
Move full flow test to the top of all tests.
All test cases should end in either CREATE_ENTRY or ABORT.
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration doesn't provide any service actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
entity-event-setup:
status: done
comment: Handled by coordinator.
entity-unique-id:
status: done
comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
has-entity-name: done
runtime-data: todo
test-before-configure: done
test-before-setup:
status: done
comment: |
Raising ConfigEntryNotReady, if either the initialization or
refresh of coordinator isn't successful.
unique-config-entry:
status: done
comment: |
As there is no unique information available from the dependency mcstatus,
the server address is used to identify that the same service is already configured.
# Silver
action-exceptions:
status: exempt
comment: Integration doesn't provide any service actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration doesn't support any configuration parameters.
docs-installation-parameters: done
entity-unavailable:
status: done
comment: Handled by coordinator.
integration-owner: done
log-when-unavailable:
status: done
comment: Handled by coordinator.
parallel-updates:
status: todo
comment: |
Although this is handled by the coordinator and no service actions are provided,
PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule.
reauthentication-flow:
status: exempt
comment: No authentication is required for the integration.
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery:
status: exempt
comment: No discovery possible.
discovery-update-info:
status: exempt
comment: |
No discovery possible. Users can use the (local or public) hostname instead of an IP address,
if static IP addresses cannot be configured.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: A minecraft server can only have one device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repair use-cases for this integration.
stale-devices: todo
# Platinum
async-dependency:
status: done
comment: |
Lookup API of the dependency mcstatus for Bedrock Edition servers is not async,
but is non-blocking and therefore OK to be called. Refer to mcstatus FAQ
https://mcstatus.readthedocs.io/en/stable/pages/faq/#why-doesn-t-bedrockserver-have-an-async-lookup-method
inject-websession:
status: exempt
comment: Integration isn't making any HTTP requests.
strict-typing: done
+1 -1
View File
@@ -35,7 +35,7 @@ async def async_setup_entry(
@callback
def _create_entity(netatmo_device: NetatmoDevice) -> None:
entity = NetatmoFan(netatmo_device)
_LOGGER.debug("Adding fan %s", entity)
_LOGGER.debug("Adding cover %s", entity)
async_add_entities([entity])
entry.async_on_unload(
+3 -11
View File
@@ -4,23 +4,15 @@ import logging
from pyownet import protocol
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .const import DOMAIN, PLATFORMS
from .onewirehub import CannotConnect, OneWireConfigEntry, OneWireHub
_LOGGER = logging.getLogger(__name__)
_PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool:
"""Set up a 1-Wire proxy for a config entry."""
@@ -35,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b
entry.runtime_data = onewire_hub
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(options_update_listener))
@@ -56,7 +48,7 @@ async def async_unload_entry(
hass: HomeAssistant, config_entry: OneWireConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS)
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def options_update_listener(
@@ -19,9 +19,7 @@ from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
PARALLEL_UPDATES = 0
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=30)
@@ -2,6 +2,8 @@
from __future__ import annotations
from homeassistant.const import Platform
DEFAULT_HOST = "localhost"
DEFAULT_PORT = 4304
@@ -52,3 +54,9 @@ MANUFACTURER_EDS = "Embedded Data Systems"
READ_MODE_BOOL = "bool"
READ_MODE_FLOAT = "float"
READ_MODE_INT = "int"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SWITCH,
]
+1 -2
View File
@@ -54,7 +54,6 @@ class OneWireEntity(Entity):
"""Return the state attributes of the entity."""
return {
"device_file": self._device_file,
# raw_value attribute is deprecated and can be removed in 2025.8
"raw_value": self._value_raw,
}
@@ -85,4 +84,4 @@ class OneWireEntity(Entity):
elif self.entity_description.read_mode == READ_MODE_BOOL:
self._state = int(self._value_raw) == 1
else:
self._state = self._value_raw
self._state = round(self._value_raw, 1)
@@ -1,126 +0,0 @@
rules:
## Bronze
config-flow:
status: todo
comment: missing data_description on options flow
test-before-configure: done
unique-config-entry:
status: done
comment: unique ID is not available, but duplicates are prevented based on host/port
config-flow-test-coverage: done
runtime-data: done
test-before-setup: done
appropriate-polling: done
entity-unique-id: done
has-entity-name: done
entity-event-setup:
status: exempt
comment: entities do not subscribe to events
dependency-transparency:
status: todo
comment: The package is not built and published inside a CI pipeline
action-setup:
status: exempt
comment: No service actions currently available
common-modules:
status: done
comment: base entity available, but no coordinator
docs-high-level-description:
status: todo
comment: Under review
docs-installation-instructions:
status: todo
comment: Under review
docs-removal-instructions:
status: todo
comment: Under review
docs-actions:
status: todo
comment: Under review
brands: done
## Silver
config-entry-unloading: done
log-when-unavailable: done
entity-unavailable: done
action-exceptions:
status: exempt
comment: No service actions currently available
reauthentication-flow:
status: exempt
comment: Local polling without authentication
parallel-updates: done
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: todo
comment: Under review
docs-configuration-parameters:
status: todo
comment: Under review
## Gold
entity-translations: done
entity-device-class: done
devices: done
entity-category: done
entity-disabled-by-default: done
discovery:
status: todo
comment: mDNS should be possible - https://owfs.org/index_php_page_avahi-discovery.html
stale-devices:
status: done
comment: >
Manual removal, as it is not possible to distinguish
between a flaky device and a device that has been removed
diagnostics:
status: todo
comment: config-entry diagnostics level available, might be nice to have device-level diagnostics
exception-translations:
status: todo
comment: Under review
icon-translations:
status: exempt
comment: It doesn't make sense to override defaults
reconfiguration-flow: done
dynamic-devices:
status: todo
comment: Not yet implemented
discovery-update-info:
status: todo
comment: Under review
repair-issues:
status: exempt
comment: No repairs available
docs-use-cases:
status: todo
comment: Under review
docs-supported-devices:
status: todo
comment: Under review
docs-supported-functions:
status: todo
comment: Under review
docs-data-update:
status: todo
comment: Under review
docs-known-limitations:
status: todo
comment: Under review
docs-troubleshooting:
status: todo
comment: Under review
docs-examples:
status: todo
comment: Under review
## Platinum
async-dependency:
status: todo
comment: The dependency is not async
inject-websession:
status: exempt
comment: No websession
strict-typing:
status: todo
comment: The dependency is not typed
@@ -1,95 +0,0 @@
"""Support for 1-Wire environment select entities."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import os
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import READ_MODE_INT
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=30)
@dataclass(frozen=True)
class OneWireSelectEntityDescription(OneWireEntityDescription, SelectEntityDescription):
"""Class describing OneWire select entities."""
ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = {
"28": (
OneWireSelectEntityDescription(
key="tempres",
entity_category=EntityCategory.CONFIG,
read_mode=READ_MODE_INT,
options=["9", "10", "11", "12"],
translation_key="tempres",
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OneWireConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
entities = await hass.async_add_executor_job(
get_entities, config_entry.runtime_data
)
async_add_entities(entities, True)
def get_entities(onewire_hub: OneWireHub) -> list[OneWireSelectEntity]:
"""Get a list of entities."""
if not onewire_hub.devices:
return []
entities: list[OneWireSelectEntity] = []
for device in onewire_hub.devices:
family = device.family
device_id = device.id
device_info = device.device_info
if family not in ENTITY_DESCRIPTIONS:
continue
for description in ENTITY_DESCRIPTIONS[family]:
device_file = os.path.join(os.path.split(device.path)[0], description.key)
entities.append(
OneWireSelectEntity(
description=description,
device_id=device_id,
device_file=device_file,
device_info=device_info,
owproxy=onewire_hub.owproxy,
)
)
return entities
class OneWireSelectEntity(OneWireEntity, SelectEntity):
"""Implementation of a 1-Wire switch."""
entity_description: OneWireSelectEntityDescription
@property
def current_option(self) -> str | None:
"""Return the selected entity option to represent the entity state."""
return str(self._state)
def select_option(self, option: str) -> None:
"""Change the selected option."""
self._write_value(option.encode("ascii"))
+1 -3
View File
@@ -41,9 +41,7 @@ from .const import (
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
PARALLEL_UPDATES = 0
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=30)
@@ -41,17 +41,6 @@
"name": "Hub short on branch {id}"
}
},
"select": {
"tempres": {
"name": "Temperature resolution",
"state": {
"9": "9 bits (0.5°C, fastest, up to 93.75ms)",
"10": "10 bits (0.25°C, up to 187.5ms)",
"11": "11 bits (0.125°C, up to 375ms)",
"12": "12 bits (0.0625°C, slowest, up to 750ms)"
}
}
},
"sensor": {
"counter_id": {
"name": "Counter {id}"
+1 -3
View File
@@ -16,9 +16,7 @@ from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_
from .entity import OneWireEntity, OneWireEntityDescription
from .onewirehub import OneWireConfigEntry, OneWireHub
# the library uses non-persistent connections
# and concurrent access to the bus is managed by the server
PARALLEL_UPDATES = 0
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(seconds=30)
@@ -17,15 +17,14 @@ from homeassistant.components.webhook import (
from homeassistant.const import CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.http import HomeAssistantView
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
from .const import DOMAIN, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
from .services import setup_services
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -130,7 +129,6 @@ class OverseerrWebhookManager:
LOGGER.debug("Received webhook payload: %s", data)
if data["notification_type"].startswith("MEDIA"):
await self.entry.runtime_data.async_refresh()
async_dispatcher_send(hass, EVENT_KEY, data)
return HomeAssistantView.json({"message": "ok"})
async def unregister_webhook(self) -> None:
+24 -22
View File
@@ -14,8 +14,6 @@ ATTR_STATUS = "status"
ATTR_SORT_ORDER = "sort_order"
ATTR_REQUESTED_BY = "requested_by"
EVENT_KEY = f"{DOMAIN}_event"
REGISTERED_NOTIFICATIONS = (
NotificationType.REQUEST_PENDING_APPROVAL
| NotificationType.REQUEST_APPROVED
@@ -25,24 +23,28 @@ REGISTERED_NOTIFICATIONS = (
| NotificationType.REQUEST_AUTOMATICALLY_APPROVED
)
JSON_PAYLOAD = (
'"{\\"notification_type\\":\\"{{notification_type}}\\",\\"subject\\":\\"{{subject}'
'}\\",\\"message\\":\\"{{message}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":'
'{\\"media_type\\":\\"{{media_type}}\\",\\"tmdb_idd\\":\\"{{media_tmdbid}}\\",\\"t'
'vdb_id\\":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"status4k'
'\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":\\"{{request_id'
'}}\\",\\"requested_by_email\\":\\"{{requestedBy_email}}\\",\\"requested_by_userna'
'me\\":\\"{{requestedBy_username}}\\",\\"requested_by_avatar\\":\\"{{requestedBy_a'
'vatar}}\\",\\"requested_by_settings_discord_id\\":\\"{{requestedBy_settings_disco'
'rdId}}\\",\\"requested_by_settings_telegram_chat_id\\":\\"{{requestedBy_settings_'
'telegramChatId}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_'
'type\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",\\"reporte'
'd_by_email\\":\\"{{reportedBy_email}}\\",\\"reported_by_username\\":\\"{{reported'
'By_username}}\\",\\"reported_by_avatar\\":\\"{{reportedBy_avatar}}\\",\\"reported'
'_by_settings_discord_id\\":\\"{{reportedBy_settings_discordId}}\\",\\"reported_by'
'_settings_telegram_chat_id\\":\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{'
'comment}}\\":{\\"comment_message\\":\\"{{comment_message}}\\",\\"commented_by_ema'
'il\\":\\"{{commentedBy_email}}\\",\\"commented_by_username\\":\\"{{commentedBy_us'
'ername}}\\",\\"commented_by_avatar\\":\\"{{commentedBy_avatar}}\\",\\"commented_b'
'y_settings_discord_id\\":\\"{{commentedBy_settings_discordId}}\\",\\"commented_by'
'_settings_telegram_chat_id\\":\\"{{commentedBy_settings_telegramChatId}}\\"}}"'
'"{\\"notification_type\\":\\"{{notification_type}}\\",\\"event\\":\\"'
'{{event}}\\",\\"subject\\":\\"{{subject}}\\",\\"message\\":\\"{{messa'
'ge}}\\",\\"image\\":\\"{{image}}\\",\\"{{media}}\\":{\\"media_type\\"'
':\\"{{media_type}}\\",\\"tmdbId\\":\\"{{media_tmdbid}}\\",\\"tvdbId\\'
'":\\"{{media_tvdbid}}\\",\\"status\\":\\"{{media_status}}\\",\\"statu'
's4k\\":\\"{{media_status4k}}\\"},\\"{{request}}\\":{\\"request_id\\":'
'\\"{{request_id}}\\",\\"requestedBy_email\\":\\"{{requestedBy_email}}'
'\\",\\"requestedBy_username\\":\\"{{requestedBy_username}}\\",\\"requ'
'estedBy_avatar\\":\\"{{requestedBy_avatar}}\\",\\"requestedBy_setting'
's_discordId\\":\\"{{requestedBy_settings_discordId}}\\",\\"requestedB'
'y_settings_telegramChatId\\":\\"{{requestedBy_settings_telegramChatId'
'}}\\"},\\"{{issue}}\\":{\\"issue_id\\":\\"{{issue_id}}\\",\\"issue_ty'
'pe\\":\\"{{issue_type}}\\",\\"issue_status\\":\\"{{issue_status}}\\",'
'\\"reportedBy_email\\":\\"{{reportedBy_email}}\\",\\"reportedBy_usern'
'ame\\":\\"{{reportedBy_username}}\\",\\"reportedBy_avatar\\":\\"{{rep'
'ortedBy_avatar}}\\",\\"reportedBy_settings_discordId\\":\\"{{reported'
'By_settings_discordId}}\\",\\"reportedBy_settings_telegramChatId\\":'
'\\"{{reportedBy_settings_telegramChatId}}\\"},\\"{{comment}}\\":{\\"c'
'omment_message\\":\\"{{comment_message}}\\",\\"commentedBy_email\\":'
'\\"{{commentedBy_email}}\\",\\"commentedBy_username\\":\\"{{commented'
'By_username}}\\",\\"commentedBy_avatar\\":\\"{{commentedBy_avatar}}'
'\\",\\"commentedBy_settings_discordId\\":\\"{{commentedBy_settings_di'
'scordId}}\\",\\"commentedBy_settings_telegramChatId\\":\\"{{commented'
'By_settings_telegramChatId}}\\"},\\"{{extra}}\\":[]\\n}"'
)
@@ -1,99 +0,0 @@
"""Support for Overseerr events."""
from dataclasses import dataclass
from typing import Any
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import EVENT_KEY
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
from .entity import OverseerrEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class OverseerrEventEntityDescription(EventEntityDescription):
"""Describes Overseerr config event entity."""
nullable_fields: list[str]
EVENTS: tuple[OverseerrEventEntityDescription, ...] = (
OverseerrEventEntityDescription(
key="media",
translation_key="last_media_event",
event_types=[
"pending",
"approved",
"available",
"failed",
"declined",
"auto_approved",
],
nullable_fields=["comment", "issue"],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: OverseerrConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Overseerr sensor entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
OverseerrEvent(coordinator, description) for description in EVENTS
)
class OverseerrEvent(OverseerrEntity, EventEntity):
"""Defines a Overseerr event entity."""
def __init__(
self,
coordinator: OverseerrCoordinator,
description: OverseerrEventEntityDescription,
) -> None:
"""Initialize Overseerr event entity."""
super().__init__(coordinator, description.key)
self.entity_description = description
self._attr_available = True
async def async_added_to_hass(self) -> None:
"""Subscribe to updates."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(self.hass, EVENT_KEY, self._handle_update)
)
async def _handle_update(self, event: dict[str, Any]) -> None:
"""Handle incoming event."""
event_type = event["notification_type"].lower()
if event_type.split("_")[0] == self.entity_description.key:
self._trigger_event(event_type[6:], event)
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
if super().available != self._attr_available:
self._attr_available = super().available
super()._handle_coordinator_update()
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._attr_available
def parse_event(event: dict[str, Any], nullable_fields: list[str]) -> dict[str, Any]:
"""Parse event."""
event.pop("notification_type")
for field in nullable_fields:
event.pop(field)
return event
@@ -21,19 +21,6 @@
}
},
"entity": {
"event": {
"last_media_event": {
"name": "Last media event",
"state": {
"pending": "Pending",
"approved": "Approved",
"available": "Available",
"failed": "Failed",
"declined": "Declined",
"auto_approved": "Auto-approved"
}
}
},
"sensor": {
"total_requests": {
"name": "Total requests"
@@ -16,6 +16,10 @@
"backup_failed_out_of_resources": {
"title": "Database backup failed due to lack of resources",
"description": "The database backup stated at {start_time} failed due to lack of resources. The backup cannot be trusted and must be restarted. This can happen if the database is too large or if the system is under heavy load. Consider upgrading the system hardware or reducing the size of the database by decreasing the number of history days to keep or creating a filter."
},
"sqlite_too_old": {
"title": "Update SQLite to {min_version} or later to continue using the recorder",
"description": "Support for version {server_version} of SQLite is ending; the minimum supported version is {min_version}. Please upgrade your database software."
}
},
"services": {
+48 -2
View File
@@ -95,8 +95,9 @@ RECOMMENDED_MIN_VERSION_MARIA_DB_108 = _simple_version("10.8.4")
MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4")
MIN_VERSION_MYSQL = _simple_version("8.0.0")
MIN_VERSION_PGSQL = _simple_version("12.0")
MIN_VERSION_SQLITE = _simple_version("3.40.1")
MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.40.1")
MIN_VERSION_SQLITE = _simple_version("3.31.0")
UPCOMING_MIN_VERSION_SQLITE = _simple_version("3.40.1")
MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0")
# This is the maximum time after the recorder ends the session
@@ -375,6 +376,37 @@ def _raise_if_version_unsupported(
raise UnsupportedDialect
@callback
def _async_delete_issue_deprecated_version(
hass: HomeAssistant, dialect_name: str
) -> None:
"""Delete the issue about upcoming unsupported database version."""
ir.async_delete_issue(hass, DOMAIN, f"{dialect_name}_too_old")
@callback
def _async_create_issue_deprecated_version(
hass: HomeAssistant,
server_version: AwesomeVersion,
dialect_name: str,
min_version: AwesomeVersion,
) -> None:
"""Warn about upcoming unsupported database version."""
ir.async_create_issue(
hass,
DOMAIN,
f"{dialect_name}_too_old",
is_fixable=False,
severity=ir.IssueSeverity.CRITICAL,
translation_key=f"{dialect_name}_too_old",
translation_placeholders={
"server_version": str(server_version),
"min_version": str(min_version),
},
breaks_in_ha_version="2025.2.0",
)
def _extract_version_from_server_response_or_raise(
server_response: str,
) -> AwesomeVersion:
@@ -491,6 +523,20 @@ def setup_connection_for_dialect(
version or version_string, "SQLite", MIN_VERSION_SQLITE
)
# No elif here since _raise_if_version_unsupported raises
if version < UPCOMING_MIN_VERSION_SQLITE:
instance.hass.add_job(
_async_create_issue_deprecated_version,
instance.hass,
version or version_string,
dialect_name,
UPCOMING_MIN_VERSION_SQLITE,
)
else:
instance.hass.add_job(
_async_delete_issue_deprecated_version, instance.hass, dialect_name
)
if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS:
max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS
@@ -89,9 +89,6 @@
"timeout": {
"message": "Timeout waiting on a response: {err}"
},
"unexpected": {
"message": "Unexpected Reolink error: {err}"
},
"firmware_install_error": {
"message": "Error trying to update Reolink firmware: {err}"
},
+2 -7
View File
@@ -82,8 +82,7 @@ def get_device_uid_and_ch(
ch = int(device_uid[1][5:])
is_chime = True
else:
device_uid_part = "_".join(device_uid[1:])
ch = host.api.channel_for_uid(device_uid_part)
ch = host.api.channel_for_uid(device_uid[1])
return (device_uid, ch, is_chime)
@@ -168,10 +167,6 @@ def raise_translated_error(
translation_placeholders={"err": str(err)},
) from err
except ReolinkError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unexpected",
translation_placeholders={"err": str(err)},
) from err
raise HomeAssistantError(err) from err
return decorator_raise_translated_error
@@ -69,7 +69,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
def __init__(self, controller, name, arm_home_mode, partition_id):
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"satel_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
@@ -58,7 +58,6 @@ class SatelIntegraSwitch(SwitchEntity):
def __init__(self, controller, device_number, device_name, code):
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"satel_switch_{device_number}"
self._name = device_name
self._state = False
self._code = code
@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from pysensibo.model import MotionSensor, SensiboDevice
@@ -19,7 +18,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import SensiboConfigEntry
from .const import LOGGER
from .coordinator import SensiboDataUpdateCoordinator
from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
@@ -124,55 +122,32 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
new_devices, remove_devices, added_devices = coordinator.get_devices(
added_devices
)
if LOGGER.isEnabledFor(logging.DEBUG):
LOGGER.debug(
"New devices: %s, Removed devices: %s, Existing devices: %s",
new_devices,
remove_devices,
added_devices,
)
if new_devices:
for device_id, device_data in coordinator.data.parsed.items():
if device_data.motion_sensors:
entities.extend(
SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description
)
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items()
if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for description in MOTION_DEVICE_SENSOR_TYPES
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors and device_id in new_devices
for description in MOTION_DEVICE_SENSOR_TYPES
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(entities)
class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity):
+4 -16
View File
@@ -41,22 +41,10 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
for device_id in coordinator.data.parsed
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(
SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES)
for device_id, device_data in coordinator.data.parsed.items()
)
class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity):
+9 -19
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from bisect import bisect_left
from typing import TYPE_CHECKING, Any
from typing import Any
import voluptuous as vol
@@ -144,22 +144,12 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
entities = [
SensiboClimate(coordinator, device_id)
for device_id, device_data in coordinator.data.parsed.items()
]
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboClimate(coordinator, device_id)
for device_id in coordinator.data.parsed
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -209,7 +199,7 @@ async def async_setup_entry(
vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
vol.Required(ATTR_SMART_TYPE): vol.In(
["temperature", "feelslike", "humidity"]
["temperature", "feelsLike", "humidity"]
),
},
"async_enable_climate_react",
@@ -265,8 +255,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity):
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available hvac operation modes."""
if TYPE_CHECKING:
assert self.device_data.hvac_modes
if not self.device_data.hvac_modes:
return [HVACMode.OFF]
return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes]
@property
@@ -12,7 +12,6 @@ from pysensibo.model import SensiboData
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -49,25 +48,6 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
session=async_get_clientsession(hass),
timeout=TIMEOUT,
)
self.previous_devices: set[str] = set()
def get_devices(
self, added_devices: set[str]
) -> tuple[set[str], set[str], set[str]]:
"""Addition and removal of devices."""
data = self.data
motion_sensors = {
sensor_id
for device_data in data.parsed.values()
if device_data.motion_sensors
for sensor_id in device_data.motion_sensors
}
devices: set[str] = set(data.parsed)
new_devices: set[str] = motion_sensors | devices - added_devices
remove_devices = added_devices - devices - motion_sensors
added_devices = (added_devices - remove_devices) | new_devices
return (new_devices, remove_devices, added_devices)
async def _async_update_data(self) -> SensiboData:
"""Fetch data from Sensibo."""
@@ -87,23 +67,4 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
if not data.raw:
raise UpdateFailed(translation_domain=DOMAIN, translation_key="no_data")
current_devices = set(data.parsed)
for device_data in data.parsed.values():
if device_data.motion_sensors:
for motion_sensor_id in device_data.motion_sensors:
current_devices.add(motion_sensor_id)
if stale_devices := self.previous_devices - current_devices:
LOGGER.debug("Removing stale devices: %s", stale_devices)
device_registry = dr.async_get(self.hass)
for _id in stale_devices:
device = device_registry.async_get_device(identifiers={(DOMAIN, _id)})
if device:
device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.previous_devices = current_devices
return data
+5 -17
View File
@@ -71,23 +71,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboNumber(coordinator, device_id, description)
for device_id in coordinator.data.parsed
for description in DEVICE_NUMBER_TYPES
if device_id in new_devices
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(
SensiboNumber(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DEVICE_NUMBER_TYPES
)
class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity):
@@ -54,7 +54,7 @@ rules:
entity-category: done
entity-disabled-by-default: done
discovery: done
stale-devices: done
stale-devices: todo
diagnostics:
status: done
comment: |
@@ -62,7 +62,7 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
dynamic-devices: done
dynamic-devices: todo
discovery-update-info:
status: exempt
comment: |
+21 -26
View File
@@ -16,6 +16,7 @@ from homeassistant.components.select import (
SelectEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import (
@@ -108,27 +109,17 @@ async def async_setup_entry(
"entity": entity_id,
},
)
entities.extend(
[
SensiboSelect(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DEVICE_SELECT_TYPES
if description.key in device_data.full_features
]
)
async_add_entities(entities)
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboSelect(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DEVICE_SELECT_TYPES
if description.key in device_data.full_features
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
"""Representation of a Sensibo Select."""
@@ -146,13 +137,6 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
@property
def available(self) -> bool:
"""Return True if entity is available."""
if self.entity_description.key not in self.device_data.active_features:
return False
return super().available
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
@@ -168,6 +152,17 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity):
async def async_select_option(self, option: str) -> None:
"""Set state to the selected option."""
if self.entity_description.key not in self.device_data.active_features:
hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else ""
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="select_option_not_available",
translation_placeholders={
"hvac_mode": hvac_mode,
"key": self.entity_description.key,
},
)
await self.async_send_api_call(
key=self.entity_description.data_key,
value=option,
+12 -34
View File
@@ -36,13 +36,6 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity
PARALLEL_UPDATES = 0
def _smart_type_name(_type: str | None) -> str | None:
"""Return a lowercase name of smart type."""
if _type and _type == "feelsLike":
return "feelslike"
return _type
@dataclass(frozen=True, kw_only=True)
class SensiboMotionSensorEntityDescription(SensorEntityDescription):
"""Describes Sensibo Motion sensor entity."""
@@ -160,7 +153,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = (
SensiboDeviceSensorEntityDescription(
key="climate_react_type",
translation_key="smart_type",
value_fn=lambda data: _smart_type_name(data.smart_type),
value_fn=lambda data: data.smart_type,
extra_fn=None,
entity_registry_enabled_default=False,
),
@@ -246,40 +239,25 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
new_devices, remove_devices, added_devices = coordinator.get_devices(
added_devices
)
if new_devices:
for device_id, device_data in coordinator.data.parsed.items():
if device_data.motion_sensors:
entities.extend(
SensiboMotionSensor(
coordinator, device_id, sensor_id, sensor_data, description
)
for device_id, device_data in coordinator.data.parsed.items()
if device_data.motion_sensors
for sensor_id, sensor_data in device_data.motion_sensors.items()
if sensor_id in new_devices
for description in MOTION_SENSOR_TYPES
)
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
entities.extend(
SensiboDeviceSensor(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SENSOR_TYPES
)
)
async_add_entities(entities)
class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity):
@@ -575,6 +575,9 @@
"service_raised": {
"message": "Could not perform action for {name} with error {error}"
},
"select_option_not_available": {
"message": "Current mode {hvac_mode} doesn't support setting {key}"
},
"climate_react_not_available": {
"message": "Use Sensibo Enable Climate React action once to enable switch or the Sensibo app"
},
+7 -19
View File
@@ -84,25 +84,13 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboDeviceSwitch(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SWITCH_TYPES
)
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(
SensiboDeviceSwitch(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
for description in DESCRIPTION_BY_MODELS.get(
device_data.model, DEVICE_SWITCH_TYPES
)
)
class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity):
+6 -18
View File
@@ -51,24 +51,12 @@ async def async_setup_entry(
coordinator = entry.runtime_data
added_devices: set[str] = set()
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
new_devices, _, added_devices = coordinator.get_devices(added_devices)
if new_devices:
async_add_entities(
SensiboDeviceUpdate(coordinator, device_id, description)
for device_id, device_data in coordinator.data.parsed.items()
if device_id in new_devices
for description in DEVICE_SENSOR_TYPES
if description.value_available(device_data) is not None
)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
async_add_entities(
SensiboDeviceUpdate(coordinator, device_id, description)
for description in DEVICE_SENSOR_TYPES
for device_id, device_data in coordinator.data.parsed.items()
if description.value_available(device_data) is not None
)
class SensiboDeviceUpdate(SensiboDeviceBaseEntity, UpdateEntity):
@@ -180,7 +180,6 @@ class StarlineAccount:
"online": device.online,
}
# Deprecated and should be removed in 2025.8
@staticmethod
def engine_attrs(device: StarlineDevice) -> dict[str, Any]:
"""Attributes for engine switch."""
@@ -43,13 +43,8 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
),
BinarySensorEntityDescription(
key="run",
translation_key="ignition",
entity_registry_enabled_default=False,
),
BinarySensorEntityDescription(
key="r_start",
translation_key="autostart",
entity_registry_enabled_default=False,
translation_key="is_running",
device_class=BinarySensorDeviceClass.RUNNING,
),
BinarySensorEntityDescription(
key="hfree",
+2 -5
View File
@@ -13,11 +13,8 @@
"moving_ban": {
"default": "mdi:car-off"
},
"ignition": {
"default": "mdi:key-variant"
},
"autostart": {
"default": "mdi:auto-mode"
"is_running": {
"default": "mdi:speedometer"
}
},
"button": {
@@ -64,11 +64,8 @@
"moving_ban": {
"name": "Moving ban"
},
"ignition": {
"name": "Ignition"
},
"autostart": {
"name": "Autostart"
"is_running": {
"name": "Running"
}
},
"device_tracker": {
@@ -72,7 +72,6 @@ class StarlineSwitch(StarlineEntity, SwitchEntity):
def extra_state_attributes(self):
"""Return the state attributes of the switch."""
if self._key == "ign":
# Deprecated and should be removed in 2025.8
return self._account.engine_attrs(self._device)
return None
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
"quality_scale": "bronze",
"requirements": ["pysuezV2==2.0.3"]
"requirements": ["pysuezV2==2.0.1"]
}
@@ -2,12 +2,12 @@
"config": {
"step": {
"user": {
"title": "Connect to The Things Network v3",
"description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.",
"title": "Connect to The Things Network v3 App",
"description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"hostname": "[%key:common::config_flow::data::host%]",
"app_id": "Application ID",
"api_key": "[%key:common::config_flow::data::api_key%]"
"access_key": "[%key:common::config_flow::data::api_key%]"
}
},
"reauth_confirm": {
@@ -1,11 +1,11 @@
rules:
# Bronze
config-flow: done
config-flow: todo
test-before-configure: done
unique-config-entry: done
config-flow-test-coverage: todo
runtime-data: done
test-before-setup: done
test-before-setup: todo
appropriate-polling: done
entity-unique-id: done
has-entity-name: done
@@ -2,36 +2,21 @@
"config": {
"step": {
"user": {
"title": "Total Connect 2.0 Account Credentials",
"description": "It is highly recommended to use a 'standard' Total Connect user account with Home Assistant. The account should not have full administrative privileges.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "The Total Connect username",
"password": "The Total Connect password"
}
},
"locations": {
"title": "Location Usercodes",
"description": "Enter the usercode for this user at location {location_id}",
"data": {
"usercodes": "Usercode"
},
"data_description": {
"usercodes": "The usercode is usually a 4 digit number"
"usercode": "Usercode"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "Total Connect needs to re-authenticate your account",
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::totalconnect::config::step::user::data_description::password%]"
}
"description": "Total Connect needs to re-authenticate your account"
}
},
"error": {
@@ -51,10 +36,6 @@
"data": {
"auto_bypass_low_battery": "Auto bypass low battery",
"code_required": "Require user to enter code for alarm actions"
},
"data_description": {
"auto_bypass_low_battery": "If enabled, Total Connect zones will immediately be bypassed when they report low battery. This option helps because zones tend to report low battery in the middle of the night. The downside of this option is that when the alarm system is armed, the bypassed zone will not be monitored.",
"code_required": "If enabled, you must enter the user code to arm or disarm the alarm"
}
}
}
+7 -30
View File
@@ -6,7 +6,7 @@ import asyncio
from collections.abc import Iterable
from datetime import timedelta
import logging
from typing import Any, cast
from typing import Any
from aiohttp import ClientSession
from kasa import (
@@ -178,23 +178,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
if not credentials and entry_credentials_hash:
data = {k: v for k, v in entry.data.items() if k != CONF_CREDENTIALS_HASH}
hass.config_entries.async_update_entry(entry, data=data)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="device_authentication",
translation_placeholders={
"func": "connect",
"exc": str(ex),
},
) from ex
raise ConfigEntryAuthFailed from ex
except KasaException as ex:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_error",
translation_placeholders={
"func": "connect",
"exc": str(ex),
},
) from ex
raise ConfigEntryNotReady from ex
device_credentials_hash = device.credentials_hash
@@ -226,14 +212,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo
# wait for the next discovery to find the device at its new address
# and update the config entry so we do not mix up devices.
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="unexpected_device",
translation_placeholders={
"host": host,
# all entries have a unique id
"expected": cast(str, entry.unique_id),
"found": found_mac,
},
f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}"
)
parent_coordinator = TPLinkDataUpdateCoordinator(hass, device, timedelta(seconds=5))
@@ -284,7 +263,7 @@ def legacy_device_id(device: Device) -> str:
return device_id.split("_")[1]
def get_device_name(device: Device, parent: Device | None = None) -> str | None:
def get_device_name(device: Device, parent: Device | None = None) -> str:
"""Get a name for the device. alias can be none on some devices."""
if device.alias:
return device.alias
@@ -299,7 +278,7 @@ def get_device_name(device: Device, parent: Device | None = None) -> str | None:
]
suffix = f" {devices.index(device.device_id) + 1}" if len(devices) > 1 else ""
return f"{device.device_type.value.capitalize()}{suffix}"
return None
return f"Unnamed {device.model}"
async def get_credentials(hass: HomeAssistant) -> Credentials | None:
@@ -346,9 +325,7 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None
)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: TPLinkConfigEntry
) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
entry_version = config_entry.version
entry_minor_version = config_entry.minor_version
@@ -23,12 +23,9 @@ from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescripti
class TPLinkBinarySensorEntityDescription(
BinarySensorEntityDescription, TPLinkFeatureEntityDescription
):
"""Base class for a TPLink feature based binary sensor entity description."""
"""Base class for a TPLink feature based sensor entity description."""
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
BINARY_SENSOR_DESCRIPTIONS: Final = (
TPLinkBinarySensorEntityDescription(
key="overheated",
@@ -42,6 +39,11 @@ BINARY_SENSOR_DESCRIPTIONS: Final = (
key="cloud_connection",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
# To be replaced & disabled per default by the upcoming update platform.
TPLinkBinarySensorEntityDescription(
key="update_available",
device_class=BinarySensorDeviceClass.UPDATE,
),
TPLinkBinarySensorEntityDescription(
key="temperature_warning",
),
@@ -29,10 +29,6 @@ class TPLinkButtonEntityDescription(
"""Base class for a TPLink feature based button entity description."""
# Coordinator is used to centralize the data updates
# For actions the integration handles locking of concurrent device request
PARALLEL_UPDATES = 0
BUTTON_DESCRIPTIONS: Final = [
TPLinkButtonEntityDescription(
key="test_alarm",

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