Merge branch 'dev' into homee-switch

This commit is contained in:
Markus Adrario 2025-02-07 11:08:26 +01:00 committed by GitHub
commit ad3f17634f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
300 changed files with 6942 additions and 3192 deletions

View File

@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.7.0 uses: sigstore/cosign-installer@v3.8.0
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"

View File

@ -975,6 +975,7 @@ jobs:
${cov_params[@]} \ ${cov_params[@]} \
-o console_output_style=count \ -o console_output_style=count \
-p no:sugar \ -p no:sugar \
--exclude-warning-annotations \
$(sed -n "${{ matrix.group }},1p" pytest_buckets.txt) \ $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt) \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
@ -1098,6 +1099,7 @@ jobs:
-o console_output_style=count \ -o console_output_style=count \
--durations=10 \ --durations=10 \
-p no:sugar \ -p no:sugar \
--exclude-warning-annotations \
--dburl=mysql://root:password@127.0.0.1/homeassistant-test \ --dburl=mysql://root:password@127.0.0.1/homeassistant-test \
tests/components/history \ tests/components/history \
tests/components/logbook \ tests/components/logbook \
@ -1228,6 +1230,7 @@ jobs:
--durations=0 \ --durations=0 \
--durations-min=10 \ --durations-min=10 \
-p no:sugar \ -p no:sugar \
--exclude-warning-annotations \
--dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \ --dburl=postgresql://postgres:password@127.0.0.1/homeassistant-test \
tests/components/history \ tests/components/history \
tests/components/logbook \ tests/components/logbook \
@ -1374,6 +1377,7 @@ jobs:
--durations=0 \ --durations=0 \
--durations-min=1 \ --durations-min=1 \
-p no:sugar \ -p no:sugar \
--exclude-warning-annotations \
tests/components/${{ matrix.group }} \ tests/components/${{ matrix.group }} \
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output

View File

@ -119,6 +119,7 @@ homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.* homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.* homeassistant.components.bond.*
homeassistant.components.braviatv.* homeassistant.components.braviatv.*
homeassistant.components.bring.*
homeassistant.components.brother.* homeassistant.components.brother.*
homeassistant.components.browser.* homeassistant.components.browser.*
homeassistant.components.bryant_evolution.* homeassistant.components.bryant_evolution.*

View File

@ -4,12 +4,11 @@ from __future__ import annotations
from airgradient import AirGradientClient from airgradient import AirGradientClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirGradientCoordinator from .coordinator import AirGradientConfigEntry, AirGradientCoordinator
PLATFORMS: list[Platform] = [ PLATFORMS: list[Platform] = [
Platform.BUTTON, Platform.BUTTON,
@ -21,9 +20,6 @@ PLATFORMS: list[Platform] = [
] ]
type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
"""Set up Airgradient from a config entry.""" """Set up Airgradient from a config entry."""
@ -31,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry)
entry.data[CONF_HOST], session=async_get_clientsession(hass) entry.data[CONF_HOST], session=async_get_clientsession(hass)
) )
coordinator = AirGradientCoordinator(hass, client) coordinator = AirGradientCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()

View File

@ -4,18 +4,17 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
from airgradient import AirGradientClient, AirGradientError, Config, Measures from airgradient import AirGradientClient, AirGradientError, Config, Measures
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
if TYPE_CHECKING: type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator]
from . import AirGradientConfigEntry
@dataclass @dataclass
@ -32,11 +31,17 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]):
config_entry: AirGradientConfigEntry config_entry: AirGradientConfigEntry
_current_version: str _current_version: str
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None: def __init__(
self,
hass: HomeAssistant,
config_entry: AirGradientConfigEntry,
client: AirGradientClient,
) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""
super().__init__( super().__init__(
hass, hass,
logger=LOGGER, logger=LOGGER,
config_entry=config_entry,
name=f"AirGradient {client.host}", name=f"AirGradient {client.host}",
update_interval=timedelta(minutes=1), update_interval=timedelta(minutes=1),
) )

View File

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairq"], "loggers": ["aioairq"],
"requirements": ["aioairq==0.4.3"] "requirements": ["aioairq==0.4.4"]
} }

View File

@ -1531,7 +1531,7 @@ async def async_api_adjust_range(
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
range_delta = directive.payload["rangeValueDelta"] range_delta = directive.payload["rangeValueDelta"]
range_delta_default = bool(directive.payload["rangeValueDeltaDefault"]) range_delta_default = bool(directive.payload["rangeValueDeltaDefault"])
response_value: int | None = 0 response_value: float | None = 0
# Cover Position # Cover Position
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":

View File

@ -387,4 +387,4 @@ def _validate_state_det_rules(state_det_rules: Any) -> list[Any] | None:
except ValueError as exc: except ValueError as exc:
_LOGGER.warning("Invalid state detection rules: %s", exc) _LOGGER.warning("Invalid state detection rules: %s", exc)
return None return None
return json_rules # type: ignore[no-any-return] return json_rules

View File

@ -39,7 +39,7 @@
"idle": "[%key:common::state::idle%]", "idle": "[%key:common::state::idle%]",
"cook": "Cooking", "cook": "Cooking",
"low_water": "Low water", "low_water": "Low water",
"ota": "Ota", "ota": "OTA update",
"provisioning": "Provisioning", "provisioning": "Provisioning",
"high_temp": "High temperature", "high_temp": "High temperature",
"device_failure": "Device failure" "device_failure": "Device failure"

View File

@ -1,5 +1,7 @@
"""Assist Satellite intents.""" """Assist Satellite intents."""
from typing import Final
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -7,6 +9,8 @@ from homeassistant.helpers import entity_registry as er, intent
from .const import DOMAIN, AssistSatelliteEntityFeature from .const import DOMAIN, AssistSatelliteEntityFeature
EXCLUDED_DOMAINS: Final[set[str]] = {"voip"}
async def async_setup_intents(hass: HomeAssistant) -> None: async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the intents.""" """Set up the intents."""
@ -30,19 +34,36 @@ class BroadcastIntentHandler(intent.IntentHandler):
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
# Find all assist satellite entities that are not the one invoking the intent # Find all assist satellite entities that are not the one invoking the intent
entities = { entities: dict[str, er.RegistryEntry] = {}
entity: entry for entity in hass.states.async_entity_ids(DOMAIN):
for entity in hass.states.async_entity_ids(DOMAIN) entry = ent_reg.async_get(entity)
if (entry := ent_reg.async_get(entity)) if (
and entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE (entry is None)
} or (
# Supports announce
not (
entry.supported_features & AssistSatelliteEntityFeature.ANNOUNCE
)
)
# Not the invoking device
or (intent_obj.device_id and (entry.device_id == intent_obj.device_id))
):
# Skip satellite
continue
if intent_obj.device_id: # Check domain of config entry against excluded domains
entities = { if (
entity: entry entry.config_entry_id
for entity, entry in entities.items() and (
if entry.device_id != intent_obj.device_id config_entry := hass.config_entries.async_get_entry(
} entry.config_entry_id
)
)
and (config_entry.domain in EXCLUDED_DOMAINS)
):
continue
entities[entity] = entry
await hass.services.async_call( await hass.services.async_call(
DOMAIN, DOMAIN,
@ -54,7 +75,6 @@ class BroadcastIntentHandler(intent.IntentHandler):
) )
response = intent_obj.create_response() response = intent_obj.create_response()
response.async_set_speech("Done")
response.response_type = intent.IntentResponseType.ACTION_DONE response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results( response.async_set_results(
success_results=[ success_results=[

View File

@ -9,6 +9,7 @@ from dataclasses import dataclass, replace
from enum import StrEnum from enum import StrEnum
import hashlib import hashlib
import io import io
from itertools import chain
import json import json
from pathlib import Path, PurePath from pathlib import Path, PurePath
import shutil import shutil
@ -827,7 +828,7 @@ class BackupManager:
password=None, password=None,
) )
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors) self.known_backups.add(written_backup.backup, agent_errors, [])
return written_backup.backup.backup_id return written_backup.backup.backup_id
async def async_create_backup( async def async_create_backup(
@ -951,12 +952,23 @@ class BackupManager:
with_automatic_settings: bool, with_automatic_settings: bool,
) -> NewBackup: ) -> NewBackup:
"""Initiate generating a backup.""" """Initiate generating a backup."""
if not agent_ids: unavailable_agents = [
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
]: ]
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") if not (
available_agents := [
agent_id for agent_id in agent_ids if agent_id in self.backup_agents
]
):
raise BackupManagerError(
f"At least one available backup agent must be selected, got {agent_ids}"
)
if unavailable_agents:
LOGGER.warning(
"Backup agents %s are not available, will backupp to %s",
unavailable_agents,
available_agents,
)
if include_all_addons and include_addons: if include_all_addons and include_addons:
raise BackupManagerError( raise BackupManagerError(
"Cannot include all addons and specify specific addons" "Cannot include all addons and specify specific addons"
@ -973,7 +985,7 @@ class BackupManager:
new_backup, new_backup,
self._backup_task, self._backup_task,
) = await self._reader_writer.async_create_backup( ) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids, agent_ids=available_agents,
backup_name=backup_name, backup_name=backup_name,
extra_metadata=extra_metadata extra_metadata=extra_metadata
| { | {
@ -992,7 +1004,9 @@ class BackupManager:
raise BackupManagerError(str(err)) from err raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task( backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings, password), self._async_finish_backup(
available_agents, unavailable_agents, with_automatic_settings, password
),
name="backup_manager_finish_backup", name="backup_manager_finish_backup",
) )
if not raise_task_error: if not raise_task_error:
@ -1009,7 +1023,11 @@ class BackupManager:
return new_backup return new_backup
async def _async_finish_backup( async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None self,
available_agents: list[str],
unavailable_agents: list[str],
with_automatic_settings: bool,
password: str | None,
) -> None: ) -> None:
"""Finish a backup.""" """Finish a backup."""
if TYPE_CHECKING: if TYPE_CHECKING:
@ -1028,7 +1046,7 @@ class BackupManager:
LOGGER.debug( LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s", "Generated new backup with backup_id %s, uploading to agents %s",
written_backup.backup.backup_id, written_backup.backup.backup_id,
agent_ids, available_agents,
) )
self.async_on_backup_event( self.async_on_backup_event(
CreateBackupEvent( CreateBackupEvent(
@ -1041,13 +1059,15 @@ class BackupManager:
try: try:
agent_errors = await self._async_upload_backup( agent_errors = await self._async_upload_backup(
backup=written_backup.backup, backup=written_backup.backup,
agent_ids=agent_ids, agent_ids=available_agents,
open_stream=written_backup.open_stream, open_stream=written_backup.open_stream,
password=password, password=password,
) )
finally: finally:
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors) self.known_backups.add(
written_backup.backup, agent_errors, unavailable_agents
)
if not agent_errors: if not agent_errors:
if with_automatic_settings: if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup # create backup was successful, update last_completed_automatic_backup
@ -1056,7 +1076,7 @@ class BackupManager:
backup_success = True backup_success = True
if with_automatic_settings: if with_automatic_settings:
self._update_issue_after_agent_upload(agent_errors) self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
# delete old backups more numerous than copies # delete old backups more numerous than copies
# try this regardless of agent errors above # try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self) await delete_backups_exceeding_configured_count(self)
@ -1216,10 +1236,10 @@ class BackupManager:
) )
def _update_issue_after_agent_upload( def _update_issue_after_agent_upload(
self, agent_errors: dict[str, Exception] self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
) -> None: ) -> None:
"""Update issue registry after a backup is uploaded to agents.""" """Update issue registry after a backup is uploaded to agents."""
if not agent_errors: if not agent_errors and not unavailable_agents:
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
return return
ir.async_create_issue( ir.async_create_issue(
@ -1233,7 +1253,13 @@ class BackupManager:
translation_key="automatic_backup_failed_upload_agents", translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={ translation_placeholders={
"failed_agents": ", ".join( "failed_agents": ", ".join(
self.backup_agents[agent_id].name for agent_id in agent_errors chain(
(
self.backup_agents[agent_id].name
for agent_id in agent_errors
),
unavailable_agents,
)
) )
}, },
) )
@ -1302,11 +1328,12 @@ class KnownBackups:
self, self,
backup: AgentBackup, backup: AgentBackup,
agent_errors: dict[str, Exception], agent_errors: dict[str, Exception],
unavailable_agents: list[str],
) -> None: ) -> None:
"""Add a backup.""" """Add a backup."""
self._backups[backup.backup_id] = KnownBackup( self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id, backup_id=backup.backup_id,
failed_agent_ids=list(agent_errors), failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
) )
self._manager.store.save() self._manager.store.save()
@ -1412,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER] manager = self._hass.data[DATA_MANAGER]
agent_config = manager.config.data.agents.get(self._local_agent_id) agent_config = manager.config.data.agents.get(self._local_agent_id)
if agent_config and not agent_config.protected: if (
self._local_agent_id in agent_ids
and agent_config
and not agent_config.protected
):
password = None password = None
backup = AgentBackup( backup = AgentBackup(

View File

@ -122,7 +122,7 @@ def read_backup(backup_path: Path) -> AgentBackup:
def suggested_filename_from_name_date(name: str, date_str: str) -> str: def suggested_filename_from_name_date(name: str, date_str: str) -> str:
"""Suggest a filename for the backup.""" """Suggest a filename for the backup."""
date = dt_util.parse_datetime(date_str, raise_on_error=True) date = dt_util.parse_datetime(date_str, raise_on_error=True)
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split()) return "_".join(f"{name} {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
def suggested_filename(backup: AgentBackup) -> str: def suggested_filename(backup: AgentBackup) -> str:

View File

@ -1,8 +1,6 @@
"""The bluesound component.""" """The bluesound component."""
from dataclasses import dataclass from pyblu import Player
from pyblu import Player, SyncStatus
from pyblu.errors import PlayerUnreachableError from pyblu.errors import PlayerUnreachableError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -14,7 +12,11 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BluesoundCoordinator from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
BluesoundRuntimeData,
)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -23,18 +25,6 @@ PLATFORMS = [
] ]
@dataclass
class BluesoundRuntimeData:
"""Bluesound data class."""
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bluesound.""" """Set up the Bluesound."""
return True return True
@ -53,7 +43,7 @@ async def async_setup_entry(
except PlayerUnreachableError as ex: except PlayerUnreachableError as ex:
raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex
coordinator = BluesoundCoordinator(hass, player, sync_status) coordinator = BluesoundCoordinator(hass, config_entry, player, sync_status)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator) config_entry.runtime_data = BluesoundRuntimeData(player, sync_status, coordinator)

View File

@ -12,6 +12,7 @@ import logging
from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu import Input, Player, Preset, Status, SyncStatus
from pyblu.errors import PlayerUnreachableError from pyblu.errors import PlayerUnreachableError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -21,6 +22,15 @@ NODE_OFFLINE_CHECK_TIMEOUT = timedelta(minutes=3)
PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15) PRESET_AND_INPUTS_INTERVAL = timedelta(minutes=15)
@dataclass
class BluesoundRuntimeData:
"""Bluesound data class."""
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
@dataclass @dataclass
class BluesoundData: class BluesoundData:
"""Define a class to hold Bluesound data.""" """Define a class to hold Bluesound data."""
@ -31,6 +41,9 @@ class BluesoundData:
inputs: list[Input] inputs: list[Input]
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]: def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
"""Cancel a task.""" """Cancel a task."""
@ -45,8 +58,14 @@ def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]
class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]): class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
"""Define an object to hold Bluesound data.""" """Define an object to hold Bluesound data."""
config_entry: BluesoundConfigEntry
def __init__( def __init__(
self, hass: HomeAssistant, player: Player, sync_status: SyncStatus self,
hass: HomeAssistant,
config_entry: BluesoundConfigEntry,
player: Player,
sync_status: SyncStatus,
) -> None: ) -> None:
"""Initialize.""" """Initialize."""
self.player = player self.player = player
@ -55,12 +74,11 @@ class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
super().__init__( super().__init__(
hass, hass,
logger=_LOGGER, logger=_LOGGER,
config_entry=config_entry,
name=sync_status.name, name=sync_status.name,
) )
async def _async_setup(self) -> None: async def _async_setup(self) -> None:
assert self.config_entry is not None
preset = await self.player.presets() preset = await self.player.presets()
inputs = await self.player.inputs() inputs = await self.player.inputs()
status = await self.player.status() status = await self.player.status()

View File

@ -20,7 +20,7 @@
"bluetooth-adapters==0.21.4", "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.2", "bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.23.4", "bluetooth-data-tools==1.23.4",
"dbus-fast==2.32.0", "dbus-fast==2.33.0",
"habluetooth==3.21.1" "habluetooth==3.21.1"
] ]
} }

View File

@ -6,19 +6,16 @@ import logging
from bring_api import Bring from bring_api import Bring
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import BringDataUpdateCoordinator from .coordinator import BringConfigEntry, BringDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""Set up Bring! from a config entry.""" """Set up Bring! from a config entry."""
@ -26,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) bring = Bring(session, entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
coordinator = BringDataUpdateCoordinator(hass, bring) coordinator = BringDataUpdateCoordinator(hass, entry, bring)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = coordinator
@ -36,6 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -68,7 +68,13 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
) )
return self.async_show_form( return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"google_play": "https://play.google.com/store/apps/details?id=ch.publisheria.bring",
"app_store": "https://itunes.apple.com/app/apple-store/id580669177",
},
) )
async def async_step_reauth( async def async_step_reauth(
@ -101,6 +107,29 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconf_entry = self._get_reconfigure_entry()
if user_input:
if not (errors := await self.validate_input(user_input)):
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconf_entry, data_updates=user_input
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA,
suggested_values={CONF_EMAIL: reconf_entry.data[CONF_EMAIL]},
),
errors=errors,
)
async def validate_input(self, user_input: Mapping[str, Any]) -> dict[str, str]: async def validate_input(self, user_input: Mapping[str, Any]) -> dict[str, str]:
"""Auth Helper.""" """Auth Helper."""

View File

@ -8,11 +8,15 @@ import logging
from bring_api import ( from bring_api import (
Bring, Bring,
BringActivityResponse,
BringAuthException, BringAuthException,
BringItemsResponse,
BringList,
BringParseException, BringParseException,
BringRequestException, BringRequestException,
BringUserSettingsResponse,
BringUsersResponse,
) )
from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse
from mashumaro.mixins.orjson import DataClassORJSONMixin from mashumaro.mixins.orjson import DataClassORJSONMixin
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -26,6 +30,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator]
@dataclass(frozen=True) @dataclass(frozen=True)
class BringData(DataClassORJSONMixin): class BringData(DataClassORJSONMixin):
@ -33,20 +39,25 @@ class BringData(DataClassORJSONMixin):
lst: BringList lst: BringList
content: BringItemsResponse content: BringItemsResponse
activity: BringActivityResponse
users: BringUsersResponse
class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
"""A Bring Data Update Coordinator.""" """A Bring Data Update Coordinator."""
config_entry: ConfigEntry config_entry: BringConfigEntry
user_settings: BringUserSettingsResponse user_settings: BringUserSettingsResponse
lists: list[BringList] lists: list[BringList]
def __init__(self, hass: HomeAssistant, bring: Bring) -> None: def __init__(
self, hass: HomeAssistant, config_entry: BringConfigEntry, bring: Bring
) -> None:
"""Initialize the Bring data coordinator.""" """Initialize the Bring data coordinator."""
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=90), update_interval=timedelta(seconds=90),
) )
@ -59,23 +70,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
try: try:
self.lists = (await self.bring.load_lists()).lists self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e
except BringParseException as e: except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e raise UpdateFailed(
except BringAuthException: translation_domain=DOMAIN,
# try to recover by refreshing access token, otherwise translation_key="setup_parse_exception",
# initiate reauth flow ) from e
try: except BringAuthException as e:
await self.bring.retrieve_new_access_token() raise ConfigEntryAuthFailed(
except (BringRequestException, BringParseException) as exc: translation_domain=DOMAIN,
raise UpdateFailed("Refreshing authentication token failed") from exc translation_key="setup_authentication_exception",
except BringAuthException as exc: translation_placeholders={CONF_EMAIL: self.bring.mail},
raise ConfigEntryAuthFailed( ) from e
translation_domain=DOMAIN,
translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail},
) from exc
return self.data
if self.previous_lists - ( if self.previous_lists - (
current_lists := {lst.listUuid for lst in self.lists} current_lists := {lst.listUuid for lst in self.lists}
@ -89,14 +98,20 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
continue continue
try: try:
items = await self.bring.get_list(lst.listUuid) items = await self.bring.get_list(lst.listUuid)
activity = await self.bring.get_activity(lst.listUuid)
users = await self.bring.get_list_users(lst.listUuid)
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed( raise UpdateFailed(
"Unable to connect and retrieve data from bring" translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e ) from e
except BringParseException as e: except BringParseException as e:
raise UpdateFailed("Unable to parse response from bring") from e raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="setup_parse_exception",
) from e
else: else:
list_dict[lst.listUuid] = BringData(lst, items) list_dict[lst.listUuid] = BringData(lst, items, activity, users)
return list_dict return list_dict

View File

@ -6,7 +6,7 @@ from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import BringConfigEntry from .coordinator import BringConfigEntry
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
@ -14,4 +14,8 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
return {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()} return {
"data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()},
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists],
"user_settings": config_entry.runtime_data.user_settings.to_dict(),
}

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from bring_api.types import BringList from bring_api import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity

View File

@ -0,0 +1,108 @@
"""Event platform for Bring integration."""
from __future__ import annotations
from dataclasses import asdict
from datetime import datetime
from bring_api import ActivityType, BringList
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import BringConfigEntry
from .coordinator import BringDataUpdateCoordinator
from .entity import BringBaseEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BringConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the event platform."""
coordinator = config_entry.runtime_data
lists_added: set[str] = set()
@callback
def add_entities() -> None:
"""Add event entities."""
nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
async_add_entities(
BringEventEntity(
coordinator,
bring_list,
)
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
class BringEventEntity(BringBaseEntity, EventEntity):
"""An event entity."""
_attr_translation_key = "activities"
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringList,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, bring_list)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{self._list_uuid}_activities"
)
self._attr_event_types = [event.name.lower() for event in ActivityType]
def _async_handle_event(self) -> None:
"""Handle the activity event."""
bring_list = self.coordinator.data[self._list_uuid]
last_event_triggered = self.state
if bring_list.activity.timeline and (
last_event_triggered is None
or datetime.fromisoformat(last_event_triggered)
< bring_list.activity.timestamp
):
activity = bring_list.activity.timeline[0]
attributes = asdict(activity.content)
attributes["last_activity_by"] = next(
x.name
for x in bring_list.users.users
if x.publicUuid == activity.content.publicUserUuid
)
self._trigger_event(
activity.type.name.lower(),
attributes,
)
self.async_write_ha_state()
@property
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
return (
f"https://api.getbring.com/rest/v2/bringusers/profilepictures/{public_uuid}"
if (public_uuid := self.state_attributes.get("publicUserUuid"))
else super().entity_picture
)
async def async_added_to_hass(self) -> None:
"""Register callbacks with your device API/library."""
await super().async_added_to_hass()
self._async_handle_event()
def _handle_coordinator_update(self) -> None:
self._async_handle_event()
return super()._handle_coordinator_update()

View File

@ -1,5 +1,10 @@
{ {
"entity": { "entity": {
"event": {
"activity": {
"default": "mdi:bell"
}
},
"sensor": { "sensor": {
"urgent": { "urgent": {
"default": "mdi:run-fast" "default": "mdi:run-fast"

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["bring_api"], "loggers": ["bring_api"],
"requirements": ["bring-api==1.0.0"] "requirements": ["bring-api==1.0.1"]
} }

View File

@ -7,7 +7,7 @@ rules:
brands: done brands: done
common-modules: done common-modules: done
config-flow-test-coverage: done config-flow-test-coverage: done
config-flow: todo config-flow: done
dependency-transparency: done dependency-transparency: done
docs-actions: done docs-actions: done
docs-high-level-description: todo docs-high-level-description: todo
@ -58,9 +58,9 @@ rules:
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: todo reconfiguration-flow: done
repair-issues: repair-issues:
status: exempt status: exempt
comment: | comment: |
@ -69,4 +69,4 @@ rules:
# Platinum # Platinum
async-dependency: done async-dependency: done
inject-websession: done inject-websession: done
strict-typing: todo strict-typing: done

View File

@ -6,9 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from bring_api import BringUserSettingsResponse from bring_api import BringList, BringUserSettingsResponse
from bring_api.const import BRING_SUPPORTED_LOCALES from bring_api.const import BRING_SUPPORTED_LOCALES
from bring_api.types import BringList
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -20,8 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import BringConfigEntry from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .coordinator import BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity from .entity import BringBaseEntity
from .util import list_language, sum_attributes from .util import list_language, sum_attributes

View File

@ -5,9 +5,15 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Bring! Grocery shopping list",
"description": "Connect your Bring! account to sync your shopping lists with Home Assistant.\n\nDon't have a Bring! account? Download the app on [Google Play for Android]({google_play}) or the [App Store for iOS]({app_store}) to sign up.",
"data": { "data": {
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address associated with your Bring! account.",
"password": "The password to login to your Bring! account."
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@ -16,21 +22,53 @@
"data": { "data": {
"email": "[%key:common::config_flow::data::email%]", "email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]" "password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "[%key:component::bring::config::step::user::data_description::email%]",
"password": "[%key:component::bring::config::step::user::data_description::email%]"
}
},
"reconfigure": {
"title": "Bring! configuration",
"description": "Update your credentials if you have changed your Bring! account email or password.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "[%key:component::bring::config::step::user::data_description::email%]",
"password": "[%key:component::bring::config::step::user::data_description::email%]"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account." "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
} }
}, },
"entity": { "entity": {
"event": {
"activities": {
"name": "Activities",
"state_attributes": {
"event_type": {
"state": {
"list_items_added": "Items added",
"list_items_changed": "Items changed",
"list_items_removed": "Items removed"
}
}
}
}
},
"sensor": { "sensor": {
"urgent": { "urgent": {
"name": "Urgent", "name": "Urgent",

View File

@ -9,10 +9,10 @@ import uuid
from bring_api import ( from bring_api import (
BringItem, BringItem,
BringItemOperation, BringItemOperation,
BringList,
BringNotificationType, BringNotificationType,
BringRequestException, BringRequestException,
) )
from bring_api.types import BringList
import voluptuous as vol import voluptuous as vol
from homeassistant.components.todo import ( from homeassistant.components.todo import (
@ -26,14 +26,13 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import BringConfigEntry
from .const import ( from .const import (
ATTR_ITEM_NAME, ATTR_ITEM_NAME,
ATTR_NOTIFICATION_TYPE, ATTR_NOTIFICATION_TYPE,
DOMAIN, DOMAIN,
SERVICE_PUSH_NOTIFICATION, SERVICE_PUSH_NOTIFICATION,
) )
from .coordinator import BringData, BringDataUpdateCoordinator from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity from .entity import BringBaseEntity
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0

View File

@ -174,7 +174,7 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
def __init__( def __init__(
self, self,
name: str, name: str | None,
entity_id: str, entity_id: str,
coordinator: CalDavUpdateCoordinator, coordinator: CalDavUpdateCoordinator,
unique_id: str | None = None, unique_id: str | None = None,

View File

@ -8,16 +8,12 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib import hashlib
import logging import logging
import random import random
from typing import Any from typing import Any, Literal
from aiohttp import ClientError, ClientTimeout from aiohttp import ClientError
from hass_nabucasa import Cloud, CloudError from hass_nabucasa import Cloud, CloudError
from hass_nabucasa.cloud_api import ( from hass_nabucasa.api import CloudApiNonRetryableError
async_files_delete_file, from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
async_files_download_details,
async_files_list,
async_files_upload_details,
)
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -28,7 +24,7 @@ from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup" _STORAGE_BACKUP: Literal["backup"] = "backup"
_RETRY_LIMIT = 5 _RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60 _RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600 _RETRY_SECONDS_MAX = 600
@ -109,63 +105,14 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Backup not found") raise BackupAgentError("Backup not found")
try: try:
details = await async_files_download_details( content = await self._cloud.files.download(
self._cloud,
storage_type=_STORAGE_BACKUP, storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(), filename=self._get_backup_filename(),
) )
except (ClientError, CloudError) as err: except CloudError as err:
raise BackupAgentError("Failed to get download details") from err raise BackupAgentError(f"Failed to download backup: {err}") from err
try: return ChunkAsyncStreamIterator(content)
resp = await self._cloud.websession.get(
details["url"],
timeout=ClientTimeout(connect=10.0, total=43200.0), # 43200s == 12h
)
resp.raise_for_status()
except ClientError as err:
raise BackupAgentError("Failed to download backup") from err
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( async def async_upload_backup(
self, self,
@ -190,7 +137,8 @@ class CloudBackupAgent(BackupAgent):
tries = 1 tries = 1
while tries <= _RETRY_LIMIT: while tries <= _RETRY_LIMIT:
try: try:
await self._async_do_upload_backup( await self._cloud.files.upload(
storage_type=_STORAGE_BACKUP,
open_stream=open_stream, open_stream=open_stream,
filename=filename, filename=filename,
base64md5hash=base64md5hash, base64md5hash=base64md5hash,
@ -198,9 +146,19 @@ class CloudBackupAgent(BackupAgent):
size=size, size=size,
) )
break break
except BackupAgentError as err: except CloudApiNonRetryableError as err:
if err.code == "NC-SH-FH-03":
raise BackupAgentError(
translation_domain=DOMAIN,
translation_key="backup_size_too_large",
translation_placeholders={
"size": str(round(size / (1024**3), 2))
},
) from err
raise BackupAgentError(f"Failed to upload backup {err}") from err
except CloudError as err:
if tries == _RETRY_LIMIT: if tries == _RETRY_LIMIT:
raise raise BackupAgentError(f"Failed to upload backup {err}") from err
tries += 1 tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX) retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info( _LOGGER.info(

View File

@ -17,6 +17,11 @@
"subscription_expiration": "Subscription expiration" "subscription_expiration": "Subscription expiration"
} }
}, },
"exceptions": {
"backup_size_too_large": {
"message": "The backup size of {size}GB is too large to be uploaded to Home Assistant Cloud."
}
},
"issues": { "issues": {
"deprecated_gender": { "deprecated_gender": {
"title": "The {deprecated_option} text-to-speech option is deprecated", "title": "The {deprecated_option} text-to-speech option is deprecated",

View File

@ -132,6 +132,7 @@ WALLETS = {
"GYD": "GYD", "GYD": "GYD",
"HKD": "HKD", "HKD": "HKD",
"HNL": "HNL", "HNL": "HNL",
"HNT": "HNT",
"HRK": "HRK", "HRK": "HRK",
"HTG": "HTG", "HTG": "HTG",
"HUF": "HUF", "HUF": "HUF",
@ -410,6 +411,7 @@ RATES = {
"GYEN": "GYEN", "GYEN": "GYEN",
"HKD": "HKD", "HKD": "HKD",
"HNL": "HNL", "HNL": "HNL",
"HNT": "HNT",
"HRK": "HRK", "HRK": "HRK",
"HTG": "HTG", "HTG": "HTG",
"HUF": "HUF", "HUF": "HUF",

View File

@ -2,19 +2,19 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Coinbase API Key Details", "title": "Coinbase API key details",
"description": "Please enter the details of your API key as provided by Coinbase.", "description": "Please enter the details of your API key as provided by Coinbase.",
"data": { "data": {
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"api_token": "API Secret" "api_token": "API secret"
} }
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API Key.", "invalid_auth_key": "API credentials rejected by Coinbase due to an invalid API key.",
"invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API Secret.", "invalid_auth_secret": "API credentials rejected by Coinbase due to an invalid API secret.",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
@ -24,12 +24,12 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"description": "Adjust Coinbase Options", "description": "Adjust Coinbase options",
"data": { "data": {
"account_balance_currencies": "Wallet balances to report.", "account_balance_currencies": "Wallet balances to report.",
"exchange_rate_currencies": "Exchange rates to report.", "exchange_rate_currencies": "Exchange rates to report.",
"exchange_base": "Base currency for exchange rate sensors.", "exchange_base": "Base currency for exchange rate sensors.",
"exchnage_rate_precision": "Number of decimal places for exchange rates." "exchange_rate_precision": "Number of decimal places for exchange rates."
} }
} }
}, },

View File

@ -43,13 +43,6 @@ def async_get_chat_log(
else: else:
history = ChatLog(hass, session.conversation_id) history = ChatLog(hass, session.conversation_id)
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_history.pop(session.conversation_id)
session.async_on_cleanup(do_cleanup)
if user_input is not None: if user_input is not None:
history.async_add_user_content(UserContent(content=user_input.text)) history.async_add_user_content(UserContent(content=user_input.text))
@ -63,6 +56,15 @@ def async_get_chat_log(
) )
return return
if session.conversation_id not in all_history:
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_history.pop(session.conversation_id)
session.async_on_cleanup(do_cleanup)
all_history[session.conversation_id] = history all_history[session.conversation_id] = history

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==2.2.0", "home-assistant-intents==2025.1.28"] "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
} }

View File

@ -8,7 +8,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eheimdigital"], "loggers": ["eheimdigital"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["eheimdigital==1.0.5"], "requirements": ["eheimdigital==1.0.6"],
"zeroconf": [ "zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
] ]

View File

@ -4,12 +4,16 @@ from __future__ import annotations
import aiohttp import aiohttp
from electrickiwi_api import ElectricKiwiApi from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException from electrickiwi_api.exceptions import ApiException, AuthException
from homeassistant.const import Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from homeassistant.helpers import (
aiohttp_client,
config_entry_oauth2_flow,
entity_registry as er,
)
from . import api from . import api
from .coordinator import ( from .coordinator import (
@ -44,7 +48,9 @@ async def async_setup_entry(
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
ek_api = ElectricKiwiApi( ek_api = ElectricKiwiApi(
api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
) )
hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api) hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, entry, ek_api)
account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api) account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api)
@ -53,6 +59,8 @@ async def async_setup_entry(
await ek_api.set_active_session() await ek_api.set_active_session()
await hop_coordinator.async_config_entry_first_refresh() await hop_coordinator.async_config_entry_first_refresh()
await account_coordinator.async_config_entry_first_refresh() await account_coordinator.async_config_entry_first_refresh()
except AuthException as err:
raise ConfigEntryAuthFailed from err
except ApiException as err: except ApiException as err:
raise ConfigEntryNotReady from err raise ConfigEntryNotReady from err
@ -70,3 +78,53 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: ElectricKiwiConfigEntry
) -> bool:
"""Migrate old entry."""
if config_entry.version == 1 and config_entry.minor_version == 1:
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, config_entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(
hass, config_entry, implementation
)
ek_api = ElectricKiwiApi(
api.ConfigEntryElectricKiwiAuth(
aiohttp_client.async_get_clientsession(hass), session
)
)
try:
await ek_api.set_active_session()
connection_details = await ek_api.get_connection_details()
except AuthException:
config_entry.async_start_reauth(hass)
return False
except ApiException:
return False
unique_id = str(ek_api.customer_number)
identifier = ek_api.electricity.identifier
hass.config_entries.async_update_entry(
config_entry, unique_id=unique_id, minor_version=2
)
entity_registry = er.async_get(hass)
entity_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
for entity in entity_entries:
assert entity.config_entry_id
entity_registry.async_update_entity(
entity.entity_id,
new_unique_id=entity.unique_id.replace(
f"{unique_id}_{connection_details.id}", f"{unique_id}_{identifier}"
),
)
return True

View File

@ -2,17 +2,16 @@
from __future__ import annotations from __future__ import annotations
from typing import cast
from aiohttp import ClientSession from aiohttp import ClientSession
from electrickiwi_api import AbstractAuth from electrickiwi_api import AbstractAuth
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from .const import API_BASE_URL from .const import API_BASE_URL
class AsyncConfigEntryAuth(AbstractAuth): class ConfigEntryElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" """Provide Electric Kiwi authentication tied to an OAuth2 based config entry."""
def __init__( def __init__(
@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token.""" """Return a valid access token."""
await self._oauth_session.async_ensure_token_valid() await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"]) return str(self._oauth_session.token["access_token"])
class ConfigFlowElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config flow."""
def __init__(
self,
hass: HomeAssistant,
token: str,
) -> None:
"""Initialize ConfigFlowFitbitApi."""
super().__init__(aiohttp_client.async_get_clientsession(hass), API_BASE_URL)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the token for the Electric Kiwi API."""
return self._token

View File

@ -6,9 +6,14 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from homeassistant.config_entries import ConfigFlowResult from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from . import api
from .const import DOMAIN, SCOPE_VALUES from .const import DOMAIN, SCOPE_VALUES
@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler(
): ):
"""Config flow to handle Electric Kiwi OAuth2 authentication.""" """Config flow to handle Electric Kiwi OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN DOMAIN = DOMAIN
@property @property
@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler(
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required.""" """Dialog that informs the user that reauth is required."""
if user_input is None: if user_input is None:
return self.async_show_form(step_id="reauth_confirm") return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={CONF_NAME: self._get_reauth_entry().title},
)
return await self.async_step_user() return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for Electric Kiwi.""" """Create an entry for Electric Kiwi."""
existing_entry = await self.async_set_unique_id(DOMAIN) ek_api = ElectricKiwiApi(
if existing_entry: api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"])
return self.async_update_reload_and_abort(existing_entry, data=data) )
return await super().async_oauth_create_entry(data)
try:
session = await ek_api.get_active_session()
except ApiException:
return self.async_abort(reason="connection_error")
unique_id = str(session.data.customer_number)
await self.async_set_unique_id(unique_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=unique_id, data=data)

View File

@ -8,4 +8,4 @@ OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize"
OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token"
API_BASE_URL = "https://api.electrickiwi.co.nz" API_BASE_URL = "https://api.electrickiwi.co.nz"
SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" SCOPE_VALUES = "read_customer_details read_connection_detail read_connection read_billing_address get_bill_address read_billing_frequency read_billing_details read_billing_bills read_billing_bill read_billing_bill_id read_billing_bill_file read_account_running_balance read_customer_account_summary read_consumption_summary download_consumption_file read_consumption_averages get_consumption_averages read_hop_intervals_config read_hop_intervals read_hop_connection read_hop_specific_connection save_hop_connection save_hop_specific_connection read_outage_contact get_outage_contact_info_for_icp read_session read_session_data_login"

View File

@ -10,7 +10,7 @@ import logging
from electrickiwi_api import ElectricKiwiApi from electrickiwi_api import ElectricKiwiApi
from electrickiwi_api.exceptions import ApiException, AuthException from electrickiwi_api.exceptions import ApiException, AuthException
from electrickiwi_api.model import AccountBalance, Hop, HopIntervals from electrickiwi_api.model import AccountSummary, Hop, HopIntervals
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData:
type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData] type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData]
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]): class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]):
"""ElectricKiwi Account Data object.""" """ElectricKiwi Account Data object."""
def __init__( def __init__(
@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
name="Electric Kiwi Account Data", name="Electric Kiwi Account Data",
update_interval=ACCOUNT_SCAN_INTERVAL, update_interval=ACCOUNT_SCAN_INTERVAL,
) )
self._ek_api = ek_api self.ek_api = ek_api
async def _async_update_data(self) -> AccountBalance: async def _async_update_data(self) -> AccountSummary:
"""Fetch data from Account balance API endpoint.""" """Fetch data from Account balance API endpoint."""
try: try:
async with asyncio.timeout(60): async with asyncio.timeout(60):
return await self._ek_api.get_account_balance() return await self.ek_api.get_account_summary()
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:
@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
# Polling interval. Will only be polled if there are subscribers. # Polling interval. Will only be polled if there are subscribers.
update_interval=HOP_SCAN_INTERVAL, update_interval=HOP_SCAN_INTERVAL,
) )
self._ek_api = ek_api self.ek_api = ek_api
self.hop_intervals: HopIntervals | None = None self.hop_intervals: HopIntervals | None = None
def get_hop_options(self) -> dict[str, int]: def get_hop_options(self) -> dict[str, int]:
@ -100,7 +100,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
async def async_update_hop(self, hop_interval: int) -> Hop: async def async_update_hop(self, hop_interval: int) -> Hop:
"""Update selected hop and data.""" """Update selected hop and data."""
try: try:
self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) self.async_set_updated_data(await self.ek_api.post_hop(hop_interval))
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:
@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
try: try:
async with asyncio.timeout(60): async with asyncio.timeout(60):
if self.hop_intervals is None: if self.hop_intervals is None:
hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() hop_intervals: HopIntervals = await self.ek_api.get_hop_intervals()
hop_intervals.intervals = OrderedDict( hop_intervals.intervals = OrderedDict(
filter( filter(
lambda pair: pair[1].active == 1, lambda pair: pair[1].active == 1,
@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
) )
self.hop_intervals = hop_intervals self.hop_intervals = hop_intervals
return await self._ek_api.get_hop() return await self.ek_api.get_hop()
except AuthException as auth_err: except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err: except ApiException as api_err:

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi", "documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["electrickiwi-api==0.8.5"] "requirements": ["electrickiwi-api==0.9.14"]
} }

View File

@ -53,8 +53,8 @@ class ElectricKiwiSelectHOPEntity(
"""Initialise the HOP selection entity.""" """Initialise the HOP selection entity."""
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description
self.values_dict = coordinator.get_hop_options() self.values_dict = coordinator.get_hop_options()

View File

@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from electrickiwi_api.model import AccountBalance, Hop from electrickiwi_api.model import AccountSummary, Hop
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
@ -39,7 +39,15 @@ ATTR_HOP_PERCENTAGE = "hop_percentage"
class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription): class ElectricKiwiAccountSensorEntityDescription(SensorEntityDescription):
"""Describes Electric Kiwi sensor entity.""" """Describes Electric Kiwi sensor entity."""
value_func: Callable[[AccountBalance], float | datetime] value_func: Callable[[AccountSummary], float | datetime]
def _get_hop_percentage(account_balance: AccountSummary) -> float:
"""Return the hop percentage from account summary."""
if power := account_balance.services.get("power"):
if connection := power.connections[0]:
return float(connection.hop_percentage)
return 0.0
ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = ( ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
@ -72,9 +80,7 @@ ACCOUNT_SENSOR_TYPES: tuple[ElectricKiwiAccountSensorEntityDescription, ...] = (
translation_key="hop_power_savings", translation_key="hop_power_savings",
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
value_func=lambda account_balance: float( value_func=_get_hop_percentage,
account_balance.connections[0].hop_percentage
),
), ),
) )
@ -165,8 +171,8 @@ class ElectricKiwiAccountEntity(
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description
@ -194,8 +200,8 @@ class ElectricKiwiHOPEntity(
super().__init__(coordinator) super().__init__(coordinator)
self._attr_unique_id = ( self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001 f"{coordinator.ek_api.customer_number}"
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001 f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
) )
self.entity_description = description self.entity_description = description

View File

@ -21,7 +21,8 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]"
}, },
"create_entry": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -3,29 +3,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
import logging
from pyfireservicerota import (
ExpiredTokenError,
FireServiceRota,
FireServiceRotaIncidents,
InvalidAuthError,
InvalidTokenError,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN, WSS_BWRURL from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
@ -40,17 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if client.token_refresh_failure: if client.token_refresh_failure:
return False return False
async def async_update_data(): coordinator = FireServiceUpdateCoordinator(hass, client, entry)
return await client.async_update()
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name="duty binary sensor",
update_method=async_update_data,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
@ -68,171 +45,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload FireServiceRota config entry.""" """Unload FireServiceRota config entry."""
await hass.async_add_executor_job( await hass.async_add_executor_job(
hass.data[DOMAIN][entry.entry_id].websocket.stop_listener hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener
) )
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
del hass.data[DOMAIN][entry.entry_id] del hass.data[DOMAIN][entry.entry_id]
return unload_ok return unload_ok
class FireServiceRotaOauth:
"""Handle authentication tokens."""
def __init__(self, hass, entry, fsr):
"""Initialize the oauth object."""
self._hass = hass
self._entry = entry
self._url = entry.data[CONF_URL]
self._username = entry.data[CONF_USERNAME]
self._fsr = fsr
async def async_refresh_tokens(self) -> bool:
"""Refresh tokens and update config entry."""
_LOGGER.debug("Refreshing authentication tokens after expiration")
try:
token_info = await self._hass.async_add_executor_job(
self._fsr.refresh_tokens
)
except (InvalidAuthError, InvalidTokenError) as err:
raise ConfigEntryAuthFailed(
"Error refreshing tokens, triggered reauth workflow"
) from err
_LOGGER.debug("Saving new tokens in config entry")
self._hass.config_entries.async_update_entry(
self._entry,
data={
"auth_implementation": DOMAIN,
CONF_URL: self._url,
CONF_USERNAME: self._username,
CONF_TOKEN: token_info,
},
)
return True
class FireServiceRotaWebSocket:
"""Define a FireServiceRota websocket manager object."""
def __init__(self, hass, entry):
"""Initialize the websocket object."""
self._hass = hass
self._entry = entry
self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident)
self.incident_data = None
def _construct_url(self) -> str:
"""Return URL with latest access token."""
return WSS_BWRURL.format(
self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"]
)
def _on_incident(self, data) -> None:
"""Received new incident, update data."""
_LOGGER.debug("Received new incident via websocket: %s", data)
self.incident_data = data
dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update")
def start_listener(self) -> None:
"""Start the websocket listener."""
_LOGGER.debug("Starting incidents listener")
self._fsr_incidents.start(self._construct_url())
def stop_listener(self) -> None:
"""Stop the websocket listener."""
_LOGGER.debug("Stopping incidents listener")
self._fsr_incidents.stop()
class FireServiceRotaClient:
"""Getting the latest data from fireservicerota."""
def __init__(self, hass, entry):
"""Initialize the data object."""
self._hass = hass
self._entry = entry
self._url = entry.data[CONF_URL]
self._tokens = entry.data[CONF_TOKEN]
self.entry_id = entry.entry_id
self.unique_id = entry.unique_id
self.token_refresh_failure = False
self.incident_id = None
self.on_duty = False
self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens)
self.oauth = FireServiceRotaOauth(
self._hass,
self._entry,
self.fsr,
)
self.websocket = FireServiceRotaWebSocket(self._hass, self._entry)
async def setup(self) -> None:
"""Set up the data client."""
await self._hass.async_add_executor_job(self.websocket.start_listener)
async def update_call(self, func, *args):
"""Perform update call and return data."""
if self.token_refresh_failure:
return None
try:
return await self._hass.async_add_executor_job(func, *args)
except (ExpiredTokenError, InvalidTokenError):
await self._hass.async_add_executor_job(self.websocket.stop_listener)
self.token_refresh_failure = True
if await self.oauth.async_refresh_tokens():
self.token_refresh_failure = False
await self._hass.async_add_executor_job(self.websocket.start_listener)
return await self._hass.async_add_executor_job(func, *args)
async def async_update(self) -> dict | None:
"""Get the latest availability data."""
data = await self.update_call(
self.fsr.get_availability, str(self._hass.config.time_zone)
)
if not data:
return None
self.on_duty = bool(data.get("available"))
_LOGGER.debug("Updated availability data: %s", data)
return data
async def async_response_update(self) -> dict | None:
"""Get the latest incident response data."""
if not self.incident_id:
return None
_LOGGER.debug("Updating response data for incident id %s", self.incident_id)
return await self.update_call(self.fsr.get_incident_response, self.incident_id)
async def async_set_response(self, value) -> None:
"""Set incident response status."""
if not self.incident_id:
return
_LOGGER.debug(
"Setting incident response for incident id '%s' to state '%s'",
self.incident_id,
value,
)
await self.update_call(self.fsr.set_incident_response, self.incident_id, value)

View File

@ -8,13 +8,10 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity
CoordinatorEntity,
DataUpdateCoordinator,
)
from . import FireServiceRotaClient
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
async def async_setup_entry( async def async_setup_entry(
@ -26,14 +23,16 @@ async def async_setup_entry(
DATA_CLIENT DATA_CLIENT
] ]
coordinator: DataUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][ coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][
entry.entry_id entry.entry_id
][DATA_COORDINATOR] ][DATA_COORDINATOR]
async_add_entities([ResponseBinarySensor(coordinator, client, entry)]) async_add_entities([ResponseBinarySensor(coordinator, client, entry)])
class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity): class ResponseBinarySensor(
CoordinatorEntity[FireServiceUpdateCoordinator], BinarySensorEntity
):
"""Representation of an FireServiceRota sensor.""" """Representation of an FireServiceRota sensor."""
_attr_has_entity_name = True _attr_has_entity_name = True
@ -41,7 +40,7 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity):
def __init__( def __init__(
self, self,
coordinator: DataUpdateCoordinator, coordinator: FireServiceUpdateCoordinator,
client: FireServiceRotaClient, client: FireServiceRotaClient,
entry: ConfigEntry, entry: ConfigEntry,
) -> None: ) -> None:

View File

@ -0,0 +1,213 @@
"""The FireServiceRota integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from pyfireservicerota import (
ExpiredTokenError,
FireServiceRota,
FireServiceRotaIncidents,
InvalidAuthError,
InvalidTokenError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, WSS_BWRURL
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]):
"""Data update coordinator for FireServiceRota."""
def __init__(
self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry
) -> None:
"""Initialize the FireServiceRota DataUpdateCoordinator."""
super().__init__(
hass,
_LOGGER,
name="duty binary sensor",
config_entry=entry,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
self.client = client
async def _async_update_data(self) -> dict | None:
"""Get the latest availability data."""
return await self.client.async_update()
class FireServiceRotaOauth:
"""Handle authentication tokens."""
def __init__(self, hass, entry, fsr):
"""Initialize the oauth object."""
self._hass = hass
self._entry = entry
self._url = entry.data[CONF_URL]
self._username = entry.data[CONF_USERNAME]
self._fsr = fsr
async def async_refresh_tokens(self) -> bool:
"""Refresh tokens and update config entry."""
_LOGGER.debug("Refreshing authentication tokens after expiration")
try:
token_info = await self._hass.async_add_executor_job(
self._fsr.refresh_tokens
)
except (InvalidAuthError, InvalidTokenError) as err:
raise ConfigEntryAuthFailed(
"Error refreshing tokens, triggered reauth workflow"
) from err
_LOGGER.debug("Saving new tokens in config entry")
self._hass.config_entries.async_update_entry(
self._entry,
data={
"auth_implementation": DOMAIN,
CONF_URL: self._url,
CONF_USERNAME: self._username,
CONF_TOKEN: token_info,
},
)
return True
class FireServiceRotaWebSocket:
"""Define a FireServiceRota websocket manager object."""
def __init__(self, hass, entry):
"""Initialize the websocket object."""
self._hass = hass
self._entry = entry
self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident)
self.incident_data = None
def _construct_url(self) -> str:
"""Return URL with latest access token."""
return WSS_BWRURL.format(
self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"]
)
def _on_incident(self, data) -> None:
"""Received new incident, update data."""
_LOGGER.debug("Received new incident via websocket: %s", data)
self.incident_data = data
dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update")
def start_listener(self) -> None:
"""Start the websocket listener."""
_LOGGER.debug("Starting incidents listener")
self._fsr_incidents.start(self._construct_url())
def stop_listener(self) -> None:
"""Stop the websocket listener."""
_LOGGER.debug("Stopping incidents listener")
self._fsr_incidents.stop()
class FireServiceRotaClient:
"""Getting the latest data from fireservicerota."""
def __init__(self, hass, entry):
"""Initialize the data object."""
self._hass = hass
self._entry = entry
self._url = entry.data[CONF_URL]
self._tokens = entry.data[CONF_TOKEN]
self.entry_id = entry.entry_id
self.unique_id = entry.unique_id
self.token_refresh_failure = False
self.incident_id = None
self.on_duty = False
self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens)
self.oauth = FireServiceRotaOauth(
self._hass,
self._entry,
self.fsr,
)
self.websocket = FireServiceRotaWebSocket(self._hass, self._entry)
async def setup(self) -> None:
"""Set up the data client."""
await self._hass.async_add_executor_job(self.websocket.start_listener)
async def update_call(self, func, *args):
"""Perform update call and return data."""
if self.token_refresh_failure:
return None
try:
return await self._hass.async_add_executor_job(func, *args)
except (ExpiredTokenError, InvalidTokenError):
await self._hass.async_add_executor_job(self.websocket.stop_listener)
self.token_refresh_failure = True
if await self.oauth.async_refresh_tokens():
self.token_refresh_failure = False
await self._hass.async_add_executor_job(self.websocket.start_listener)
return await self._hass.async_add_executor_job(func, *args)
async def async_update(self) -> dict | None:
"""Get the latest availability data."""
data = await self.update_call(
self.fsr.get_availability, str(self._hass.config.time_zone)
)
if not data:
return None
self.on_duty = bool(data.get("available"))
_LOGGER.debug("Updated availability data: %s", data)
return data
async def async_response_update(self) -> dict | None:
"""Get the latest incident response data."""
if not self.incident_id:
return None
_LOGGER.debug("Updating response data for incident id %s", self.incident_id)
return await self.update_call(self.fsr.get_incident_response, self.incident_id)
async def async_set_response(self, value) -> None:
"""Set incident response status."""
if not self.incident_id:
return
_LOGGER.debug(
"Setting incident response for incident id '%s' to state '%s'",
self.incident_id,
value,
)
await self.update_call(self.fsr.set_incident_response, self.incident_id, value)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/fireservicerota", "documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyfireservicerota"], "loggers": ["pyfireservicerota"],
"requirements": ["pyfireservicerota==0.0.43"] "requirements": ["pyfireservicerota==0.0.46"]
} }

View File

@ -7,7 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from . import api from . import api
from .const import DOMAIN, FitbitScope from .const import FitbitScope
from .coordinator import FitbitData, FitbitDeviceCoordinator from .coordinator import FitbitData, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException from .exceptions import FitbitApiException, FitbitAuthException
from .model import config_from_entry_data from .model import config_from_entry_data
@ -15,10 +15,11 @@ from .model import config_from_entry_data
PLATFORMS: list[Platform] = [Platform.SENSOR] PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: type FitbitConfigEntry = ConfigEntry[FitbitData]
"""Set up fitbit from a config entry."""
hass.data.setdefault(DOMAIN, {})
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
"""Set up fitbit from a config entry."""
implementation = ( implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation( await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry hass, entry
@ -41,18 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = FitbitDeviceCoordinator(hass, fitbit_api) coordinator = FitbitDeviceCoordinator(hass, fitbit_api)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = FitbitData( entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator)
api=fitbit_api, device_coordinator=coordinator
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -14,7 +14,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
PERCENTAGE, PERCENTAGE,
EntityCategory, EntityCategory,
@ -29,9 +28,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FitbitConfigEntry
from .api import FitbitApi from .api import FitbitApi
from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem from .const import ATTRIBUTION, BATTERY_LEVELS, DOMAIN, FitbitScope, FitbitUnitSystem
from .coordinator import FitbitData, FitbitDeviceCoordinator from .coordinator import FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException from .exceptions import FitbitApiException, FitbitAuthException
from .model import FitbitDevice, config_from_entry_data from .model import FitbitDevice, config_from_entry_data
@ -131,7 +131,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription):
def _build_device_info( def _build_device_info(
config_entry: ConfigEntry, entity_description: FitbitSensorEntityDescription config_entry: FitbitConfigEntry, entity_description: FitbitSensorEntityDescription
) -> DeviceInfo: ) -> DeviceInfo:
"""Build device info for sensor entities info across devices.""" """Build device info for sensor entities info across devices."""
unique_id = cast(str, config_entry.unique_id) unique_id = cast(str, config_entry.unique_id)
@ -524,12 +524,12 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: FitbitConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Fitbit sensor platform.""" """Set up the Fitbit sensor platform."""
data: FitbitData = hass.data[DOMAIN][entry.entry_id] data = entry.runtime_data
api = data.api api = data.api
# These are run serially to reuse the cached user profile, not gathered # These are run serially to reuse the cached user profile, not gathered
@ -601,7 +601,7 @@ class FitbitSensor(SensorEntity):
def __init__( def __init__(
self, self,
config_entry: ConfigEntry, config_entry: FitbitConfigEntry,
api: FitbitApi, api: FitbitApi,
user_profile_id: str, user_profile_id: str,
description: FitbitSensorEntityDescription, description: FitbitSensorEntityDescription,

View File

@ -2,7 +2,6 @@
from libpyfoscam import FoscamCamera from libpyfoscam import FoscamCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
CONF_PASSWORD, CONF_PASSWORD,
@ -14,13 +13,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .config_flow import DEFAULT_RTSP_PORT from .config_flow import DEFAULT_RTSP_PORT
from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET from .const import CONF_RTSP_PORT, LOGGER
from .coordinator import FoscamCoordinator from .coordinator import FoscamConfigEntry, FoscamCoordinator
PLATFORMS = [Platform.CAMERA, Platform.SWITCH] PLATFORMS = [Platform.CAMERA, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
"""Set up foscam from a config entry.""" """Set up foscam from a config entry."""
session = FoscamCamera( session = FoscamCamera(
@ -30,11 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data[CONF_PASSWORD], entry.data[CONF_PASSWORD],
verbose=False, verbose=False,
) )
coordinator = FoscamCoordinator(hass, session) coordinator = FoscamCoordinator(hass, entry, session)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator entry.runtime_data = coordinator
# Migrate to correct unique IDs for switches # Migrate to correct unique IDs for switches
await async_migrate_entities(hass, entry) await async_migrate_entities(hass, entry)
@ -44,20 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ)
hass.services.async_remove(domain=DOMAIN, service=SERVICE_PTZ_PRESET)
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
"""Migrate old entry.""" """Migrate old entry."""
LOGGER.debug("Migrating from version %s", entry.version) LOGGER.debug("Migrating from version %s", entry.version)
@ -97,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_migrate_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_migrate_entities(hass: HomeAssistant, entry: FoscamConfigEntry) -> None:
"""Migrate old entry.""" """Migrate old entry."""
@callback @callback

View File

@ -7,21 +7,13 @@ import asyncio
import voluptuous as vol import voluptuous as vol
from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
CONF_RTSP_PORT, from .coordinator import FoscamConfigEntry, FoscamCoordinator
CONF_STREAM,
DOMAIN,
LOGGER,
SERVICE_PTZ,
SERVICE_PTZ_PRESET,
)
from .coordinator import FoscamCoordinator
from .entity import FoscamEntity from .entity import FoscamEntity
DIR_UP = "up" DIR_UP = "up"
@ -56,7 +48,7 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: FoscamConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Add a Foscam IP camera from a config entry.""" """Add a Foscam IP camera from a config entry."""
@ -89,7 +81,7 @@ async def async_setup_entry(
"async_perform_ptz_preset", "async_perform_ptz_preset",
) )
coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = config_entry.runtime_data
async_add_entities([HassFoscamCamera(coordinator, config_entry)]) async_add_entities([HassFoscamCamera(coordinator, config_entry)])
@ -103,7 +95,7 @@ class HassFoscamCamera(FoscamEntity, Camera):
def __init__( def __init__(
self, self,
coordinator: FoscamCoordinator, coordinator: FoscamCoordinator,
config_entry: ConfigEntry, config_entry: FoscamConfigEntry,
) -> None: ) -> None:
"""Initialize a Foscam camera.""" """Initialize a Foscam camera."""
super().__init__(coordinator, config_entry.entry_id) super().__init__(coordinator, config_entry.entry_id)

View File

@ -6,11 +6,14 @@ from typing import Any
from libpyfoscam import FoscamCamera from libpyfoscam import FoscamCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
type FoscamConfigEntry = ConfigEntry[FoscamCoordinator]
class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Foscam coordinator.""" """Foscam coordinator."""
@ -18,12 +21,14 @@ class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
entry: FoscamConfigEntry,
session: FoscamCamera, session: FoscamCamera,
) -> None: ) -> None:
"""Initialize my coordinator.""" """Initialize my coordinator."""
super().__init__( super().__init__(
hass, hass,
LOGGER, LOGGER,
config_entry=entry,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=30), update_interval=timedelta(seconds=30),
) )

View File

@ -5,24 +5,23 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FoscamCoordinator from .const import LOGGER
from .const import DOMAIN, LOGGER from .coordinator import FoscamConfigEntry, FoscamCoordinator
from .entity import FoscamEntity from .entity import FoscamEntity
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: FoscamConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up foscam switch from a config entry.""" """Set up foscam switch from a config entry."""
coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = config_entry.runtime_data
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
@ -36,7 +35,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
def __init__( def __init__(
self, self,
coordinator: FoscamCoordinator, coordinator: FoscamCoordinator,
config_entry: ConfigEntry, config_entry: FoscamConfigEntry,
) -> None: ) -> None:
"""Initialize a Foscam Sleep Switch.""" """Initialize a Foscam Sleep Switch."""
super().__init__(coordinator, config_entry.entry_id) super().__init__(coordinator, config_entry.entry_id)

View File

@ -2,17 +2,12 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Final from typing import Final
from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
from .coordinator import FreedomproDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: Final[list[Platform]] = [ PLATFORMS: Final[list[Platform]] = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
@ -26,32 +21,27 @@ PLATFORMS: Final[list[Platform]] = [
] ]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool:
"""Set up Freedompro from a config entry.""" """Set up Freedompro from a config entry."""
hass.data.setdefault(DOMAIN, {}) coordinator = FreedomproDataUpdateCoordinator(hass, entry)
api_key = entry.data[CONF_API_KEY]
coordinator = FreedomproDataUpdateCoordinator(hass, api_key)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.async_on_unload(entry.add_update_listener(update_listener)) entry.async_on_unload(entry.add_update_listener(update_listener))
hass.data[DOMAIN][entry.entry_id] = coordinator entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: FreedomproConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: async def update_listener(
hass: HomeAssistant, config_entry: FreedomproConfigEntry
) -> None:
"""Update listener.""" """Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id) await hass.config_entries.async_reload(config_entry.entry_id)

View File

@ -6,14 +6,13 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass, BinarySensorDeviceClass,
BinarySensorEntity, BinarySensorEntity,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
DEVICE_CLASS_MAP = { DEVICE_CLASS_MAP = {
"smokeSensor": BinarySensorDeviceClass.SMOKE, "smokeSensor": BinarySensorDeviceClass.SMOKE,
@ -33,10 +32,12 @@ SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactS
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro binary_sensor.""" """Set up Freedompro binary_sensor."""
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(device, coordinator) Device(device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -15,7 +15,6 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, HVACMode,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -24,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -44,11 +43,13 @@ SUPPORTED_HVAC_MODES = [
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro climate.""" """Set up Freedompro climate."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device( Device(
aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator

View File

@ -8,6 +8,9 @@ from typing import Any
from pyfreedompro import get_list, get_states from pyfreedompro import get_list, get_states
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -15,18 +18,27 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
type FreedomproConfigEntry = ConfigEntry[FreedomproDataUpdateCoordinator]
class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Class to manage fetching Freedompro data API.""" """Class to manage fetching Freedompro data API."""
def __init__(self, hass, api_key): def __init__(self, hass: HomeAssistant, entry: FreedomproConfigEntry) -> None:
"""Initialize.""" """Initialize."""
self._hass = hass self._hass = hass
self._api_key = api_key self._api_key = entry.data[CONF_API_KEY]
self._devices: list[dict[str, Any]] | None = None self._devices: list[dict[str, Any]] | None = None
update_interval = timedelta(minutes=1) update_interval = timedelta(minutes=1)
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=update_interval,
)
async def _async_update_data(self): async def _async_update_data(self):
if self._devices is None: if self._devices is None:

View File

@ -11,7 +11,6 @@ from homeassistant.components.cover import (
CoverEntity, CoverEntity,
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -20,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
DEVICE_CLASS_MAP = { DEVICE_CLASS_MAP = {
"windowCovering": CoverDeviceClass.BLIND, "windowCovering": CoverDeviceClass.BLIND,
@ -34,11 +33,13 @@ SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro cover.""" """Set up Freedompro cover."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(hass, api_key, device, coordinator) Device(hass, api_key, device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -8,7 +8,6 @@ from typing import Any
from pyfreedompro import put_state from pyfreedompro import put_state
from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -17,15 +16,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro fan.""" """Set up Freedompro fan."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
FreedomproFan(hass, api_key, device, coordinator) FreedomproFan(hass, api_key, device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -13,7 +13,6 @@ from homeassistant.components.light import (
ColorMode, ColorMode,
LightEntity, LightEntity,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -22,15 +21,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro light.""" """Set up Freedompro light."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(hass, api_key, device, coordinator) Device(hass, api_key, device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -6,7 +6,6 @@ from typing import Any
from pyfreedompro import put_state from pyfreedompro import put_state
from homeassistant.components.lock import LockEntity from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro lock.""" """Set up Freedompro lock."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(hass, api_key, device, coordinator) Device(hass, api_key, device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -15,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
DEVICE_CLASS_MAP = { DEVICE_CLASS_MAP = {
"temperatureSensor": SensorDeviceClass.TEMPERATURE, "temperatureSensor": SensorDeviceClass.TEMPERATURE,
@ -41,10 +40,12 @@ SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro sensor.""" """Set up Freedompro sensor."""
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(device, coordinator) Device(device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -6,7 +6,6 @@ from typing import Any
from pyfreedompro import put_state from pyfreedompro import put_state
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client from homeassistant.helpers import aiohttp_client
@ -15,15 +14,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up Freedompro switch.""" """Set up Freedompro switch."""
api_key: str = entry.data[CONF_API_KEY] api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator = entry.runtime_data
async_add_entities( async_add_entities(
Device(hass, api_key, device, coordinator) Device(hass, api_key, device, coordinator)
for device in coordinator.data for device in coordinator.data

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250204.0"] "requirements": ["home-assistant-frontend==20250205.0"]
} }

View File

@ -14,5 +14,5 @@
}, },
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["ismartgate"], "loggers": ["ismartgate"],
"requirements": ["ismartgate==5.0.1"] "requirements": ["ismartgate==5.0.2"]
} }

View File

@ -8,7 +8,7 @@ CONF_PROMPT = "prompt"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest" RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash"
CONF_TEMPERATURE = "temperature" CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p" CONF_TOP_P = "top_p"

View File

@ -38,6 +38,10 @@
"local_name": "GV5126*", "local_name": "GV5126*",
"connectable": false "connectable": false
}, },
{
"local_name": "GV5179*",
"connectable": false
},
{ {
"local_name": "GVH5127*", "local_name": "GVH5127*",
"connectable": false "connectable": false
@ -131,5 +135,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble", "documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["govee-ble==0.42.1"] "requirements": ["govee-ble==0.43.0"]
} }

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from govee_local_api import GoveeDevice, GoveeLightCapability from govee_local_api import GoveeDevice, GoveeLightFeatures
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
@ -71,13 +71,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
capabilities = device.capabilities capabilities = device.capabilities
color_modes = {ColorMode.ONOFF} color_modes = {ColorMode.ONOFF}
if capabilities: if capabilities:
if GoveeLightCapability.COLOR_RGB in capabilities: if GoveeLightFeatures.COLOR_RGB & capabilities.features:
color_modes.add(ColorMode.RGB) color_modes.add(ColorMode.RGB)
if GoveeLightCapability.COLOR_KELVIN_TEMPERATURE in capabilities: if GoveeLightFeatures.COLOR_KELVIN_TEMPERATURE & capabilities.features:
color_modes.add(ColorMode.COLOR_TEMP) color_modes.add(ColorMode.COLOR_TEMP)
self._attr_max_color_temp_kelvin = 9000 self._attr_max_color_temp_kelvin = 9000
self._attr_min_color_temp_kelvin = 2000 self._attr_min_color_temp_kelvin = 2000
if GoveeLightCapability.BRIGHTNESS in capabilities: if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
color_modes.add(ColorMode.BRIGHTNESS) color_modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(color_modes) self._attr_supported_color_modes = filter_supported_color_modes(color_modes)

View File

@ -6,5 +6,5 @@
"dependencies": ["network"], "dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local", "documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["govee-local-api==1.5.3"] "requirements": ["govee-local-api==2.0.1"]
} }

View File

@ -16,6 +16,12 @@
}, },
"elevation": { "elevation": {
"default": "mdi:arrow-up-down" "default": "mdi:arrow-up-down"
},
"total_satellites": {
"default": "mdi:satellite-variant"
},
"used_satellites": {
"default": "mdi:satellite-variant"
} }
} }
} }

View File

@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
import logging import logging
from typing import Any
from gps3.agps3threaded import AGPS3mechanism from gps3.agps3threaded import AGPS3mechanism
@ -14,6 +13,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_LATITUDE, ATTR_LATITUDE,
@ -37,14 +37,32 @@ _LOGGER = logging.getLogger(__name__)
ATTR_CLIMB = "climb" ATTR_CLIMB = "climb"
ATTR_ELEVATION = "elevation" ATTR_ELEVATION = "elevation"
ATTR_GPS_TIME = "gps_time"
ATTR_SPEED = "speed" ATTR_SPEED = "speed"
ATTR_TOTAL_SATELLITES = "total_satellites"
ATTR_USED_SATELLITES = "used_satellites"
DEFAULT_NAME = "GPS" DEFAULT_NAME = "GPS"
_MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} _MODE_VALUES = {2: "2d_fix", 3: "3d_fix"}
def count_total_satellites_fn(agps_thread: AGPS3mechanism) -> int | None:
"""Count the number of total satellites."""
satellites = agps_thread.data_stream.satellites
return None if satellites == "n/a" else len(satellites)
def count_used_satellites_fn(agps_thread: AGPS3mechanism) -> int | None:
"""Count the number of used satellites."""
satellites = agps_thread.data_stream.satellites
if satellites == "n/a":
return None
return sum(
1 for sat in satellites if isinstance(sat, dict) and sat.get("used", False)
)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class GpsdSensorDescription(SensorEntityDescription): class GpsdSensorDescription(SensorEntityDescription):
"""Class describing GPSD sensor entities.""" """Class describing GPSD sensor entities."""
@ -116,6 +134,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = (
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
GpsdSensorDescription(
key=ATTR_TOTAL_SATELLITES,
translation_key=ATTR_TOTAL_SATELLITES,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=count_total_satellites_fn,
entity_registry_enabled_default=False,
),
GpsdSensorDescription(
key=ATTR_USED_SATELLITES,
translation_key=ATTR_USED_SATELLITES,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=count_used_satellites_fn,
entity_registry_enabled_default=False,
),
) )
@ -165,21 +199,3 @@ class GpsdSensor(SensorEntity):
"""Return the state of GPSD.""" """Return the state of GPSD."""
value = self.entity_description.value_fn(self.agps_thread) value = self.entity_description.value_fn(self.agps_thread)
return None if value == "n/a" else value return None if value == "n/a" else value
# Deprecated since Home Assistant 2024.9.0
# Can be removed completely in 2025.3.0
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes of the GPS."""
if self.entity_description.key != ATTR_MODE:
return None
return {
ATTR_LATITUDE: self.agps_thread.data_stream.lat,
ATTR_LONGITUDE: self.agps_thread.data_stream.lon,
ATTR_ELEVATION: self.agps_thread.data_stream.alt,
ATTR_GPS_TIME: self.agps_thread.data_stream.time,
ATTR_SPEED: self.agps_thread.data_stream.speed,
ATTR_CLIMB: self.agps_thread.data_stream.climb,
ATTR_MODE: self.agps_thread.data_stream.mode,
}

View File

@ -50,6 +50,14 @@
}, },
"mode": { "name": "[%key:common::config_flow::data::mode%]" } "mode": { "name": "[%key:common::config_flow::data::mode%]" }
} }
},
"total_satellites": {
"name": "Total satellites",
"unit_of_measurement": "satellites"
},
"used_satellites": {
"name": "Used satellites",
"unit_of_measurement": "satellites"
} }
} }
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/habitica", "documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["habiticalib"], "loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.4"] "requirements": ["habiticalib==0.3.5"]
} }

View File

@ -510,7 +510,8 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
or (task.notes and keyword in task.notes.lower()) or (task.notes and keyword in task.notes.lower())
or any(keyword in item.text.lower() for item in task.checklist) or any(keyword in item.text.lower() for item in task.checklist)
] ]
result: dict[str, Any] = {"tasks": response} result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]}
return result return result
hass.services.async_register( hass.services.async_register(

View File

@ -20,6 +20,7 @@ from aiohasupervisor.models import (
backups as supervisor_backups, backups as supervisor_backups,
mounts as supervisor_mounts, mounts as supervisor_mounts,
) )
from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE
from homeassistant.components.backup import ( from homeassistant.components.backup import (
DATA_MANAGER, DATA_MANAGER,
@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client from .handler import get_supervisor_client
LOCATION_CLOUD_BACKUP = ".cloud_backup"
LOCATION_LOCAL = ".local"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
# Set on backups automatically created when updating an addon # Set on backups automatically created when updating an addon
@ -72,7 +71,9 @@ async def async_get_backup_agents(
"""Return the hassio backup agents.""" """Return the hassio backup agents."""
client = get_supervisor_client(hass) client = get_supervisor_client(hass)
mounts = await client.mounts.info() mounts = await client.mounts.info()
agents: list[BackupAgent] = [SupervisorBackupAgent(hass, "local", None)] agents: list[BackupAgent] = [
SupervisorBackupAgent(hass, "local", LOCATION_LOCAL_STORAGE)
]
for mount in mounts.mounts: for mount in mounts.mounts:
if mount.usage is not supervisor_mounts.MountUsage.BACKUP: if mount.usage is not supervisor_mounts.MountUsage.BACKUP:
continue continue
@ -112,7 +113,7 @@ def async_register_backup_agents_listener(
def _backup_details_to_agent_backup( def _backup_details_to_agent_backup(
details: supervisor_backups.BackupComplete, location: str | None details: supervisor_backups.BackupComplete, location: str
) -> AgentBackup: ) -> AgentBackup:
"""Convert a supervisor backup details object to an agent backup.""" """Convert a supervisor backup details object to an agent backup."""
homeassistant_included = details.homeassistant is not None homeassistant_included = details.homeassistant is not None
@ -125,7 +126,6 @@ def _backup_details_to_agent_backup(
for addon in details.addons for addon in details.addons
] ]
extra_metadata = details.extra or {} extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL
return AgentBackup( return AgentBackup(
addons=addons, addons=addons,
backup_id=details.slug, backup_id=details.slug,
@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent):
domain = DOMAIN domain = DOMAIN
def __init__(self, hass: HomeAssistant, name: str, location: str | None) -> None: def __init__(self, hass: HomeAssistant, name: str, location: str) -> None:
"""Initialize the backup agent.""" """Initialize the backup agent."""
super().__init__() super().__init__()
self._hass = hass self._hass = hass
@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent):
backup_list = await self._client.backups.list() backup_list = await self._client.backups.list()
result = [] result = []
for backup in backup_list: for backup in backup_list:
if not backup.locations or self.location not in backup.locations: if self.location not in backup.location_attributes:
continue continue
details = await self._client.backups.backup_info(backup.slug) details = await self._client.backups.backup_info(backup.slug)
result.append(_backup_details_to_agent_backup(details, self.location)) result.append(_backup_details_to_agent_backup(details, self.location))
@ -222,7 +222,7 @@ class SupervisorBackupAgent(BackupAgent):
details = await self._client.backups.backup_info(backup_id) details = await self._client.backups.backup_info(backup_id)
except SupervisorNotFoundError: except SupervisorNotFoundError:
return None return None
if self.location not in details.locations: if self.location not in details.location_attributes:
return None return None
return _backup_details_to_agent_backup(details, self.location) return _backup_details_to_agent_backup(details, self.location)
@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
# will be handled by async_upload_backup. # will be handled by async_upload_backup.
# If the lists are the same length, it does not matter which one we send, # If the lists are the same length, it does not matter which one we send,
# we send the encrypted list to have a well defined behavior. # we send the encrypted list to have a well defined behavior.
encrypted_locations: list[str | None] = [] encrypted_locations: list[str] = []
decrypted_locations: list[str | None] = [] decrypted_locations: list[str] = []
agents_settings = manager.config.data.agents agents_settings = manager.config.data.agents
for hassio_agent in hassio_agents: for hassio_agent in hassio_agents:
if password is not None: if password is not None:
@ -353,12 +353,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
eager_start=False, # To ensure the task is not started before we return eager_start=False, # To ensure the task is not started before we return
) )
return (NewBackup(backup_job_id=backup.job_id), backup_task) return (NewBackup(backup_job_id=backup.job_id.hex), backup_task)
async def _async_wait_for_backup( async def _async_wait_for_backup(
self, self,
backup: supervisor_backups.NewBackup, backup: supervisor_backups.NewBackup,
locations: list[str | None], locations: list[str],
*, *,
on_progress: Callable[[CreateBackupEvent], None], on_progress: Callable[[CreateBackupEvent], None],
remove_after_upload: bool, remove_after_upload: bool,
@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
else None else None
) )
restore_location: str | None restore_location: str
if manager.backup_agents[agent_id].domain != DOMAIN: if manager.backup_agents[agent_id].domain != DOMAIN:
# Download the backup to the supervisor. Supervisor will clean up the backup # Download the backup to the supervisor. Supervisor will clean up the backup
# two days after the restore is done. # two days after the restore is done.
@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None: ) -> None:
"""Check restore status after core restart.""" """Check restore status after core restart."""
if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)): if not (restore_job_str := os.environ.get(RESTORE_JOB_ID_ENV)):
_LOGGER.debug("No restore job ID found in environment") _LOGGER.debug("No restore job ID found in environment")
return return
restore_job_id = UUID(restore_job_str)
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id) _LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
sent_event = False sent_event = False
@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
@callback @callback
def _async_listen_job_events( def _async_listen_job_events(
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
) -> Callable[[], None]: ) -> Callable[[], None]:
"""Listen for job events.""" """Listen for job events."""
@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
if ( if (
data.get("event") != "job" data.get("event") != "job"
or not (event_data := data.get("data")) or not (event_data := data.get("data"))
or event_data.get("uuid") != job_id or event_data.get("uuid") != job_id.hex
): ):
return return
on_event(event_data) on_event(event_data)
@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
return unsub return unsub
async def _get_job_state( async def _get_job_state(
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
) -> None: ) -> None:
"""Poll a job for its state.""" """Poll a job for its state."""
job = await self._client.jobs.get_job(UUID(job_id)) job = await self._client.jobs.get_job(job_id)
_LOGGER.debug("Job state: %s", job) _LOGGER.debug("Job state: %s", job)
on_event(job.to_dict()) on_event(job.to_dict())

View File

@ -295,6 +295,8 @@ def async_remove_addons_from_dev_reg(
class HassioDataUpdateCoordinator(DataUpdateCoordinator): class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status.""" """Class to retrieve Hass.io status."""
config_entry: ConfigEntry
def __init__( def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry
) -> None: ) -> None:
@ -302,6 +304,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
super().__init__( super().__init__(
hass, hass,
_LOGGER, _LOGGER,
config_entry=config_entry,
name=DOMAIN, name=DOMAIN,
update_interval=HASSIO_UPDATE_INTERVAL, update_interval=HASSIO_UPDATE_INTERVAL,
# We don't want an immediate refresh since we want to avoid # We don't want an immediate refresh since we want to avoid

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio", "documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling", "iot_class": "local_polling",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["aiohasupervisor==0.2.2b6"], "requirements": ["aiohasupervisor==0.3.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -82,12 +82,20 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
try: try:
await self.heos.connect() await self.heos.connect()
except HeosError as error: except HeosError as error:
raise ConfigEntryNotReady from error _LOGGER.debug("Unable to connect to %s", self.host, exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="unable_to_connect",
translation_placeholders={"host": self.host},
) from error
# Load players # Load players
try: try:
await self.heos.get_players() await self.heos.get_players()
except HeosError as error: except HeosError as error:
raise ConfigEntryNotReady from error _LOGGER.debug("Unexpected error retrieving players", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="unable_to_get_players"
) from error
if not self.heos.is_signed_in: if not self.heos.is_signed_in:
_LOGGER.warning( _LOGGER.warning(

View File

@ -54,7 +54,7 @@ rules:
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
entity-translations: done entity-translations: done
exception-translations: todo exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: done reconfiguration-flow: done
repair-issues: todo repair-issues: todo

View File

@ -112,6 +112,12 @@
"not_heos_media_player": { "not_heos_media_player": {
"message": "Entity {entity_id} is not a HEOS media player entity" "message": "Entity {entity_id} is not a HEOS media player entity"
}, },
"unable_to_connect": {
"message": "Unable to connect to {host}"
},
"unable_to_get_players": {
"message": "Unexpected error retrieving players"
},
"unknown_source": { "unknown_source": {
"message": "Unknown source: {source}" "message": "Unknown source: {source}"
} }

View File

@ -5,11 +5,11 @@
"data": { "data": {
"name": "[%key:common::config_flow::data::name%]", "name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]", "api_key": "[%key:common::config_flow::data::api_key%]",
"mode": "Travel Mode" "mode": "Travel mode"
} }
}, },
"origin_menu": { "origin_menu": {
"title": "Choose Origin", "title": "Choose origin",
"menu_options": { "menu_options": {
"origin_coordinates": "Using a map location", "origin_coordinates": "Using a map location",
"origin_entity": "Using an entity" "origin_entity": "Using an entity"
@ -28,7 +28,7 @@
} }
}, },
"destination_menu": { "destination_menu": {
"title": "Choose Destination", "title": "Choose destination",
"menu_options": { "menu_options": {
"destination_coordinates": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_coordinates%]", "destination_coordinates": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_coordinates%]",
"destination_entity": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_entity%]" "destination_entity": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_entity%]"
@ -60,13 +60,13 @@
"step": { "step": {
"init": { "init": {
"data": { "data": {
"traffic_mode": "Traffic Mode", "traffic_mode": "Traffic mode",
"route_mode": "Route Mode", "route_mode": "Route mode",
"unit_system": "Unit system" "unit_system": "Unit system"
} }
}, },
"time_menu": { "time_menu": {
"title": "Choose Time Type", "title": "Choose time type",
"menu_options": { "menu_options": {
"departure_time": "Configure a departure time", "departure_time": "Configure a departure time",
"arrival_time": "Configure an arrival time", "arrival_time": "Configure an arrival time",
@ -74,15 +74,15 @@
} }
}, },
"departure_time": { "departure_time": {
"title": "Choose Departure Time", "title": "Choose departure time",
"data": { "data": {
"departure_time": "Departure Time" "departure_time": "Departure time"
} }
}, },
"arrival_time": { "arrival_time": {
"title": "Choose Arrival Time", "title": "Choose arrival time",
"data": { "data": {
"arrival_time": "Arrival Time" "arrival_time": "Arrival time"
} }
} }
} }

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.65", "babel==2.15.0"] "requirements": ["holidays==0.66", "babel==2.15.0"]
} }

View File

@ -6,10 +6,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
DOMAIN = "homeassistant_hardware" from .const import DATA_COMPONENT, DOMAIN
from .helpers import HardwareInfoDispatcher
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component.""" """Set up the component."""
hass.data[DATA_COMPONENT] = HardwareInfoDispatcher(hass)
return True return True

View File

@ -1,10 +1,23 @@
"""Constants for the Homeassistant Hardware integration.""" """Constants for the Homeassistant Hardware integration."""
from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .helpers import HardwareInfoDispatcher
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
DOMAIN = "homeassistant_hardware"
DATA_COMPONENT: HassKey[HardwareInfoDispatcher] = HassKey(DOMAIN)
ZHA_DOMAIN = "zha" ZHA_DOMAIN = "zha"
OTBR_DOMAIN = "otbr"
OTBR_ADDON_NAME = "OpenThread Border Router" OTBR_ADDON_NAME = "OpenThread Border Router"
OTBR_ADDON_MANAGER_DATA = "openthread_border_router" OTBR_ADDON_MANAGER_DATA = "openthread_border_router"

View File

@ -25,12 +25,14 @@ from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from . import silabs_multiprotocol_addon from . import silabs_multiprotocol_addon
from .const import ZHA_DOMAIN from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import ( from .util import (
ApplicationType, ApplicationType,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager, get_otbr_addon_manager,
get_zha_device_path,
get_zigbee_flasher_addon_manager, get_zigbee_flasher_addon_manager,
guess_hardware_owners,
probe_silabs_firmware_type, probe_silabs_firmware_type,
) )
@ -519,19 +521,15 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Pick Zigbee firmware.""" """Pick Zigbee firmware."""
assert self._device is not None assert self._device is not None
owners = await guess_hardware_owners(self.hass, self._device)
if is_hassio(self.hass): for info in owners:
otbr_manager = get_otbr_addon_manager(self.hass) for owner in info.owners:
otbr_addon_info = await self._async_get_addon_info(otbr_manager) if info.source == OTBR_DOMAIN and isinstance(owner, OwningAddon):
raise AbortFlow(
if ( "otbr_still_using_stick",
otbr_addon_info.state != AddonState.NOT_INSTALLED description_placeholders=self._get_translation_placeholders(),
and otbr_addon_info.options.get("device") == self._device )
):
raise AbortFlow(
"otbr_still_using_stick",
description_placeholders=self._get_translation_placeholders(),
)
return await super().async_step_pick_firmware_zigbee(user_input) return await super().async_step_pick_firmware_zigbee(user_input)
@ -541,15 +539,14 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Pick Thread firmware.""" """Pick Thread firmware."""
assert self._device is not None assert self._device is not None
for zha_entry in self.hass.config_entries.async_entries( owners = await guess_hardware_owners(self.hass, self._device)
ZHA_DOMAIN,
include_ignore=False, for info in owners:
include_disabled=True, for owner in info.owners:
): if info.source == ZHA_DOMAIN and isinstance(owner, OwningIntegration):
if get_zha_device_path(zha_entry) == self._device: raise AbortFlow(
raise AbortFlow( "zha_still_using_stick",
"zha_still_using_stick", description_placeholders=self._get_translation_placeholders(),
description_placeholders=self._get_translation_placeholders(), )
)
return await super().async_step_pick_firmware_thread(user_input) return await super().async_step_pick_firmware_thread(user_input)

View File

@ -0,0 +1,143 @@
"""Home Assistant Hardware integration helpers."""
from collections import defaultdict
from collections.abc import AsyncIterator, Awaitable, Callable
import logging
from typing import Protocol
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from . import DATA_COMPONENT
from .util import FirmwareInfo
_LOGGER = logging.getLogger(__name__)
class SyncHardwareFirmwareInfoModule(Protocol):
"""Protocol type for Home Assistant Hardware firmware info platform modules."""
def get_firmware_info(
self,
hass: HomeAssistant,
entry: ConfigEntry,
) -> FirmwareInfo | None:
"""Return radio firmware information for the config entry, synchronously."""
class AsyncHardwareFirmwareInfoModule(Protocol):
"""Protocol type for Home Assistant Hardware firmware info platform modules."""
async def async_get_firmware_info(
self,
hass: HomeAssistant,
entry: ConfigEntry,
) -> FirmwareInfo | None:
"""Return radio firmware information for the config entry, asynchronously."""
type HardwareFirmwareInfoModule = (
SyncHardwareFirmwareInfoModule | AsyncHardwareFirmwareInfoModule
)
class HardwareInfoDispatcher:
"""Central dispatcher for hardware/firmware information."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the dispatcher."""
self.hass = hass
self._providers: dict[str, HardwareFirmwareInfoModule] = {}
self._notification_callbacks: defaultdict[
str, set[Callable[[FirmwareInfo], None]]
] = defaultdict(set)
def register_firmware_info_provider(
self, domain: str, platform: HardwareFirmwareInfoModule
) -> None:
"""Register a firmware info provider."""
if domain in self._providers:
raise ValueError(
f"Domain {domain} is already registered as a firmware info provider"
)
# There is no need to handle "unregistration" because integrations cannot be
# wholly removed at runtime
self._providers[domain] = platform
_LOGGER.debug(
"Registered firmware info provider from domain %r: %s", domain, platform
)
def register_firmware_info_callback(
self, device: str, callback: Callable[[FirmwareInfo], None]
) -> CALLBACK_TYPE:
"""Register a firmware info notification callback."""
self._notification_callbacks[device].add(callback)
@hass_callback
def async_remove_callback() -> None:
self._notification_callbacks[device].discard(callback)
return async_remove_callback
async def notify_firmware_info(
self, domain: str, firmware_info: FirmwareInfo
) -> None:
"""Notify the dispatcher of new firmware information."""
_LOGGER.debug(
"Received firmware info notification from %r: %s", domain, firmware_info
)
for callback in self._notification_callbacks.get(firmware_info.device, []):
try:
callback(firmware_info)
except Exception:
_LOGGER.exception(
"Error while notifying firmware info listener %s", callback
)
async def iter_firmware_info(self) -> AsyncIterator[FirmwareInfo]:
"""Iterate over all firmware information for all hardware."""
for domain, fw_info_module in self._providers.items():
for config_entry in self.hass.config_entries.async_entries(domain):
try:
if hasattr(fw_info_module, "get_firmware_info"):
fw_info = fw_info_module.get_firmware_info(
self.hass, config_entry
)
else:
fw_info = await fw_info_module.async_get_firmware_info(
self.hass, config_entry
)
except Exception:
_LOGGER.exception(
"Error while getting firmware info from %r", fw_info_module
)
continue
if fw_info is not None:
yield fw_info
@hass_callback
def async_register_firmware_info_provider(
hass: HomeAssistant, domain: str, platform: HardwareFirmwareInfoModule
) -> None:
"""Register a firmware info provider."""
return hass.data[DATA_COMPONENT].register_firmware_info_provider(domain, platform)
@hass_callback
def async_register_firmware_info_callback(
hass: HomeAssistant, device: str, callback: Callable[[FirmwareInfo], None]
) -> CALLBACK_TYPE:
"""Register a firmware info provider."""
return hass.data[DATA_COMPONENT].register_firmware_info_callback(device, callback)
@hass_callback
def async_notify_firmware_info(
hass: HomeAssistant, domain: str, firmware_info: FirmwareInfo
) -> Awaitable[None]:
"""Notify the dispatcher of new firmware information."""
return hass.data[DATA_COMPONENT].notify_firmware_info(domain, firmware_info)

View File

@ -2,27 +2,27 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import logging import logging
from typing import cast
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.flasher import Flasher from universal_silabs_flasher.flasher import Flasher
from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import ( from .const import (
OTBR_ADDON_MANAGER_DATA, OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME, OTBR_ADDON_NAME,
OTBR_ADDON_SLUG, OTBR_ADDON_SLUG,
ZHA_DOMAIN,
ZIGBEE_FLASHER_ADDON_MANAGER_DATA, ZIGBEE_FLASHER_ADDON_MANAGER_DATA,
ZIGBEE_FLASHER_ADDON_NAME, ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG, ZIGBEE_FLASHER_ADDON_SLUG,
@ -55,11 +55,6 @@ class ApplicationType(StrEnum):
return FlasherApplicationType(self.value) return FlasherApplicationType(self.value)
def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
"""Get the device path from a ZHA config entry."""
return cast(str | None, config_entry.data.get("device", {}).get("path", None))
@singleton(OTBR_ADDON_MANAGER_DATA) @singleton(OTBR_ADDON_MANAGER_DATA)
@callback @callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager: def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
@ -84,31 +79,80 @@ def get_zigbee_flasher_addon_manager(hass: HomeAssistant) -> WaitingAddonManager
) )
@dataclass(slots=True, kw_only=True) @dataclass(kw_only=True)
class FirmwareGuess: class OwningAddon:
"""Owning add-on."""
slug: str
def _get_addon_manager(self, hass: HomeAssistant) -> WaitingAddonManager:
return WaitingAddonManager(
hass,
_LOGGER,
f"Add-on {self.slug}",
self.slug,
)
async def is_running(self, hass: HomeAssistant) -> bool:
"""Check if the add-on is running."""
addon_manager = self._get_addon_manager(hass)
try:
addon_info = await addon_manager.async_get_addon_info()
except AddonError:
return False
else:
return addon_info.state == AddonState.RUNNING
@dataclass(kw_only=True)
class OwningIntegration:
"""Owning integration."""
config_entry_id: str
async def is_running(self, hass: HomeAssistant) -> bool:
"""Check if the integration is running."""
if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None:
return False
return entry.state in (
ConfigEntryState.LOADED,
ConfigEntryState.SETUP_RETRY,
ConfigEntryState.SETUP_IN_PROGRESS,
)
@dataclass(kw_only=True)
class FirmwareInfo:
"""Firmware guess.""" """Firmware guess."""
is_running: bool device: str
firmware_type: ApplicationType firmware_type: ApplicationType
firmware_version: str | None
source: str source: str
owners: list[OwningAddon | OwningIntegration]
async def is_running(self, hass: HomeAssistant) -> bool:
"""Check if the firmware owner is running."""
states = await asyncio.gather(*(o.is_running(hass) for o in self.owners))
if not states:
return False
return all(states)
async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> FirmwareGuess: async def guess_hardware_owners(
"""Guess the firmware type based on installed addons and other integrations.""" hass: HomeAssistant, device_path: str
device_guesses: defaultdict[str | None, list[FirmwareGuess]] = defaultdict(list) ) -> list[FirmwareInfo]:
"""Guess the firmware info based on installed addons and other integrations."""
device_guesses: defaultdict[str, list[FirmwareInfo]] = defaultdict(list)
for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN): async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info():
zha_path = get_zha_device_path(zha_config_entry) device_guesses[firmware_info.device].append(firmware_info)
if zha_path is not None:
device_guesses[zha_path].append(
FirmwareGuess(
is_running=(zha_config_entry.state == ConfigEntryState.LOADED),
firmware_type=ApplicationType.EZSP,
source="zha",
)
)
# It may be possible for the OTBR addon to be present without the integration
if is_hassio(hass): if is_hassio(hass):
otbr_addon_manager = get_otbr_addon_manager(hass) otbr_addon_manager = get_otbr_addon_manager(hass)
@ -119,14 +163,22 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
else: else:
if otbr_addon_info.state != AddonState.NOT_INSTALLED: if otbr_addon_info.state != AddonState.NOT_INSTALLED:
otbr_path = otbr_addon_info.options.get("device") otbr_path = otbr_addon_info.options.get("device")
device_guesses[otbr_path].append(
FirmwareGuess(
is_running=(otbr_addon_info.state == AddonState.RUNNING),
firmware_type=ApplicationType.SPINEL,
source="otbr",
)
)
# Only create a new entry if there are no existing OTBR ones
if otbr_path is not None and not any(
info.source == "otbr" for info in device_guesses[otbr_path]
):
device_guesses[otbr_path].append(
FirmwareInfo(
device=otbr_path,
firmware_type=ApplicationType.SPINEL,
firmware_version=None,
source="otbr",
owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
)
)
if is_hassio(hass):
multipan_addon_manager = await get_multiprotocol_addon_manager(hass) multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
try: try:
@ -136,30 +188,48 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
else: else:
if multipan_addon_info.state != AddonState.NOT_INSTALLED: if multipan_addon_info.state != AddonState.NOT_INSTALLED:
multipan_path = multipan_addon_info.options.get("device") multipan_path = multipan_addon_info.options.get("device")
device_guesses[multipan_path].append(
FirmwareGuess(
is_running=(multipan_addon_info.state == AddonState.RUNNING),
firmware_type=ApplicationType.CPC,
source="multiprotocol",
)
)
# Fall back to EZSP if we can't guess the firmware type if multipan_path is not None:
if device_path not in device_guesses: device_guesses[multipan_path].append(
return FirmwareGuess( FirmwareInfo(
is_running=False, firmware_type=ApplicationType.EZSP, source="unknown" device=multipan_path,
firmware_type=ApplicationType.CPC,
firmware_version=None,
source="multiprotocol",
owners=[
OwningAddon(slug=multipan_addon_manager.addon_slug)
],
)
)
return device_guesses.get(device_path, [])
async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> FirmwareInfo:
"""Guess the firmware type based on installed addons and other integrations."""
hardware_owners = await guess_hardware_owners(hass, device_path)
# Fall back to EZSP if we have no way to guess
if not hardware_owners:
return FirmwareInfo(
device=device_path,
firmware_type=ApplicationType.EZSP,
firmware_version=None,
source="unknown",
owners=[],
) )
# Prioritizes guesses that were pulled from a running addon or integration but keep # Prioritize guesses that are pulled from a real source
# the sort order we defined above guesses = [
guesses = sorted( (guess, sum([await owner.is_running(hass) for owner in guess.owners]))
device_guesses[device_path], for guess in hardware_owners
key=lambda guess: guess.is_running, ]
) guesses.sort(key=lambda p: p[1])
assert guesses assert guesses
return guesses[-1] # Pick the best one. We use a stable sort so ZHA < OTBR < multi-PAN
return guesses[-1][0]
async def probe_silabs_firmware_type( async def probe_silabs_firmware_type(

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from homeassistant.components.homeassistant_hardware.util import guess_firmware_type from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -33,7 +33,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# Add-on startup with type service get started before Core, always (e.g. the # Add-on startup with type service get started before Core, always (e.g. the
# Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # Multi-Protocol add-on). Probing the firmware would interfere with the add-on,
# so we can't safely probe here. Instead, we must make an educated guess! # so we can't safely probe here. Instead, we must make an educated guess!
firmware_guess = await guess_firmware_type( firmware_guess = await guess_firmware_info(
hass, config_entry.data["device"] hass, config_entry.data["device"]
) )

View File

@ -10,7 +10,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
) )
from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.components.homeassistant_hardware.util import (
ApplicationType, ApplicationType,
guess_firmware_type, guess_firmware_info,
) )
from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -75,7 +75,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# Add-on startup with type service get started before Core, always (e.g. the # Add-on startup with type service get started before Core, always (e.g. the
# Multi-Protocol add-on). Probing the firmware would interfere with the add-on, # Multi-Protocol add-on). Probing the firmware would interfere with the add-on,
# so we can't safely probe here. Instead, we must make an educated guess! # so we can't safely probe here. Instead, we must make an educated guess!
firmware_guess = await guess_firmware_type(hass, RADIO_DEVICE) firmware_guess = await guess_firmware_info(hass, RADIO_DEVICE)
new_data = {**config_entry.data} new_data = {**config_entry.data}
new_data[FIRMWARE] = firmware_guess.firmware_type.value new_data[FIRMWARE] = firmware_guess.firmware_type.value

View File

@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import ( from homeassistant.const import (
ATTR_VIA_DEVICE, ATTR_VIA_DEVICE,
PERCENTAGE, PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory, EntityCategory,
UnitOfApparentPower, UnitOfApparentPower,
UnitOfElectricCurrent, UnitOfElectricCurrent,
@ -137,6 +138,21 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
else None else None
), ),
), ),
HomeWizardSensorEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=(
lambda data: data.system is not None
and data.system.wifi_rssi_db is not None
),
value_fn=(
lambda data: data.system.wifi_rssi_db if data.system is not None else None
),
),
HomeWizardSensorEntityDescription( HomeWizardSensorEntityDescription(
key="total_power_import_kwh", key="total_power_import_kwh",
translation_key="total_energy_import_kwh", translation_key="total_energy_import_kwh",

View File

@ -78,6 +78,9 @@
"wifi_strength": { "wifi_strength": {
"name": "Wi-Fi strength" "name": "Wi-Fi strength"
}, },
"wifi_rssi": {
"name": "Wi-Fi RSSI"
},
"total_energy_import_kwh": { "total_energy_import_kwh": {
"name": "Energy import" "name": "Energy import"
}, },

View File

@ -5,7 +5,7 @@
"title": "Connect to the PowerView Hub", "title": "Connect to the PowerView Hub",
"data": { "data": {
"host": "[%key:common::config_flow::data::ip%]", "host": "[%key:common::config_flow::data::ip%]",
"api_version": "Hub Generation" "api_version": "Hub generation"
}, },
"data_description": { "data_description": {
"api_version": "API version is detectable, but you can override and force a specific version" "api_version": "API version is detectable, but you can override and force a specific version"
@ -19,7 +19,7 @@
"flow_title": "{name} ({host})", "flow_title": "{name} ({host})",
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unsupported_device": "Only the primary powerview hub can be added", "unsupported_device": "Only the primary PowerView Hub can be added",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {

View File

@ -36,7 +36,7 @@
"entity": { "entity": {
"binary_sensor": { "binary_sensor": {
"jvc_power": { "jvc_power": {
"name": "[%key:component::sensor::entity_component::power::name%]" "name": "[%key:component::binary_sensor::entity_component::power::name%]"
} }
}, },
"select": { "select": {

View File

@ -2,7 +2,7 @@
"services": { "services": {
"request_data": { "request_data": {
"name": "Request data", "name": "Request data",
"description": "Requesta new data from the charging station." "description": "Requests new data from the charging station."
}, },
"authorize": { "authorize": {
"name": "Authorize", "name": "Authorize",
@ -46,7 +46,7 @@
"fields": { "fields": {
"failsafe_timeout": { "failsafe_timeout": {
"name": "Failsafe timeout", "name": "Failsafe timeout",
"description": "Timeout after which the failsafe mode is triggered, if set_current was not executed during this time." "description": "Timeout after which the failsafe mode is triggered if the 'Set current' action was not run during this time."
}, },
"failsafe_fallback": { "failsafe_fallback": {
"name": "Failsafe fallback", "name": "Failsafe fallback",
@ -54,7 +54,7 @@
}, },
"failsafe_persist": { "failsafe_persist": {
"name": "Failsafe persist", "name": "Failsafe persist",
"description": "If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot." "description": "If set to 0, the failsafe option will be disabled after a charging station reboot. If set to 1, the failsafe option will survive a reboot."
} }
} }
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["lacrosse_view"], "loggers": ["lacrosse_view"],
"requirements": ["lacrosse-view==1.0.4"] "requirements": ["lacrosse-view==1.1.1"]
} }

View File

@ -45,7 +45,7 @@ class LaCrosseSensorEntityDescription(SensorEntityDescription):
def get_value(sensor: Sensor, field: str) -> float | int | str | None: def get_value(sensor: Sensor, field: str) -> float | int | str | None:
"""Get the value of a sensor field.""" """Get the value of a sensor field."""
field_data = sensor.data.get(field) field_data = sensor.data.get(field) if sensor.data is not None else None
if field_data is None: if field_data is None:
return None return None
value = field_data["values"][-1]["s"] value = field_data["values"][-1]["s"]
@ -178,7 +178,7 @@ async def async_setup_entry(
continue continue
# if the API returns a different unit of measurement from the description, update it # if the API returns a different unit of measurement from the description, update it
if sensor.data.get(field) is not None: if sensor.data is not None and sensor.data.get(field) is not None:
native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get(
sensor.data[field].get("unit") sensor.data[field].get("unit")
) )
@ -240,7 +240,9 @@ class LaCrosseViewSensor(
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available."""
data = self.coordinator.data[self.index].data
return ( return (
super().available super().available
and self.entity_description.key in self.coordinator.data[self.index].data and data is not None
and self.entity_description.key in data
) )

View File

@ -7,8 +7,11 @@
"express_mode": { "express_mode": {
"default": "mdi:snowflake-variant" "default": "mdi:snowflake-variant"
}, },
"express_fridge": {
"default": "mdi:snowflake"
},
"hot_water_mode": { "hot_water_mode": {
"default": "mdi:list-status" "default": "mdi:heat-wave"
}, },
"humidity_warm_mode": { "humidity_warm_mode": {
"default": "mdi:heat-wave" "default": "mdi:heat-wave"
@ -39,6 +42,9 @@
}, },
"warm_mode": { "warm_mode": {
"default": "mdi:heat-wave" "default": "mdi:heat-wave"
},
"display_light": {
"default": "mdi:lightbulb-on-outline"
} }
}, },
"binary_sensor": { "binary_sensor": {

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lg_thinq", "documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["thinqconnect"], "loggers": ["thinqconnect"],
"requirements": ["thinqconnect==1.0.2"] "requirements": ["thinqconnect==1.0.4"]
} }

View File

@ -30,10 +30,13 @@
"name": "Auto mode" "name": "Auto mode"
}, },
"express_mode": { "express_mode": {
"name": "Ice plus" "name": "Express mode"
},
"express_fridge": {
"name": "Express cool"
}, },
"hot_water_mode": { "hot_water_mode": {
"name": "Hot water" "name": "Heating water"
}, },
"humidity_warm_mode": { "humidity_warm_mode": {
"name": "Warm mist" "name": "Warm mist"
@ -64,6 +67,9 @@
}, },
"warm_mode": { "warm_mode": {
"name": "Heating" "name": "Heating"
},
"display_light": {
"name": "Lighting"
} }
}, },
"binary_sensor": { "binary_sensor": {

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