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
- name: Install Cosign
uses: sigstore/cosign-installer@v3.7.0
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: "v2.2.3"

View File

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

View File

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

View File

@ -4,12 +4,11 @@ from __future__ import annotations
from airgradient import AirGradientClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import AirGradientCoordinator
from .coordinator import AirGradientConfigEntry, AirGradientCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
@ -21,9 +20,6 @@ PLATFORMS: list[Platform] = [
]
type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool:
"""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)
)
coordinator = AirGradientCoordinator(hass, client)
coordinator = AirGradientCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()

View File

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

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"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}
range_delta = directive.payload["rangeValueDelta"]
range_delta_default = bool(directive.payload["rangeValueDeltaDefault"])
response_value: int | None = 0
response_value: float | None = 0
# Cover 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:
_LOGGER.warning("Invalid state detection rules: %s", exc)
return None
return json_rules # type: ignore[no-any-return]
return json_rules

View File

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

View File

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

View File

@ -9,6 +9,7 @@ from dataclasses import dataclass, replace
from enum import StrEnum
import hashlib
import io
from itertools import chain
import json
from pathlib import Path, PurePath
import shutil
@ -827,7 +828,7 @@ class BackupManager:
password=None,
)
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
async def async_create_backup(
@ -951,12 +952,23 @@ class BackupManager:
with_automatic_settings: bool,
) -> NewBackup:
"""Initiate generating a backup."""
if not agent_ids:
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
unavailable_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:
raise BackupManagerError(
"Cannot include all addons and specify specific addons"
@ -973,7 +985,7 @@ class BackupManager:
new_backup,
self._backup_task,
) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids,
agent_ids=available_agents,
backup_name=backup_name,
extra_metadata=extra_metadata
| {
@ -992,7 +1004,9 @@ class BackupManager:
raise BackupManagerError(str(err)) from err
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",
)
if not raise_task_error:
@ -1009,7 +1023,11 @@ class BackupManager:
return new_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:
"""Finish a backup."""
if TYPE_CHECKING:
@ -1028,7 +1046,7 @@ class BackupManager:
LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s",
written_backup.backup.backup_id,
agent_ids,
available_agents,
)
self.async_on_backup_event(
CreateBackupEvent(
@ -1041,13 +1059,15 @@ class BackupManager:
try:
agent_errors = await self._async_upload_backup(
backup=written_backup.backup,
agent_ids=agent_ids,
agent_ids=available_agents,
open_stream=written_backup.open_stream,
password=password,
)
finally:
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 with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup
@ -1056,7 +1076,7 @@ class BackupManager:
backup_success = True
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
# try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self)
@ -1216,10 +1236,10 @@ class BackupManager:
)
def _update_issue_after_agent_upload(
self, agent_errors: dict[str, Exception]
self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
) -> None:
"""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")
return
ir.async_create_issue(
@ -1233,7 +1253,13 @@ class BackupManager:
translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={
"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,
backup: AgentBackup,
agent_errors: dict[str, Exception],
unavailable_agents: list[str],
) -> None:
"""Add a backup."""
self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id,
failed_agent_ids=list(agent_errors),
failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
)
self._manager.store.save()
@ -1412,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER]
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
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:
"""Suggest a filename for the backup."""
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:

View File

@ -1,8 +1,6 @@
"""The bluesound component."""
from dataclasses import dataclass
from pyblu import Player, SyncStatus
from pyblu import Player
from pyblu.errors import PlayerUnreachableError
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 .const import DOMAIN
from .coordinator import BluesoundCoordinator
from .coordinator import (
BluesoundConfigEntry,
BluesoundCoordinator,
BluesoundRuntimeData,
)
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:
"""Set up the Bluesound."""
return True
@ -53,7 +43,7 @@ async def async_setup_entry(
except PlayerUnreachableError as 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()
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.errors import PlayerUnreachableError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
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)
@dataclass
class BluesoundRuntimeData:
"""Bluesound data class."""
player: Player
sync_status: SyncStatus
coordinator: BluesoundCoordinator
@dataclass
class BluesoundData:
"""Define a class to hold Bluesound data."""
@ -31,6 +41,9 @@ class BluesoundData:
inputs: list[Input]
type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData]
def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]:
"""Cancel a task."""
@ -45,8 +58,14 @@ def cancel_task(task: asyncio.Task) -> Callable[[], Coroutine[None, None, None]]
class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
"""Define an object to hold Bluesound data."""
config_entry: BluesoundConfigEntry
def __init__(
self, hass: HomeAssistant, player: Player, sync_status: SyncStatus
self,
hass: HomeAssistant,
config_entry: BluesoundConfigEntry,
player: Player,
sync_status: SyncStatus,
) -> None:
"""Initialize."""
self.player = player
@ -55,12 +74,11 @@ class BluesoundCoordinator(DataUpdateCoordinator[BluesoundData]):
super().__init__(
hass,
logger=_LOGGER,
config_entry=config_entry,
name=sync_status.name,
)
async def _async_setup(self) -> None:
assert self.config_entry is not None
preset = await self.player.presets()
inputs = await self.player.inputs()
status = await self.player.status()

View File

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

View File

@ -6,19 +6,16 @@ import logging
from bring_api import Bring
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
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__)
type BringConfigEntry = ConfigEntry[BringDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""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)
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()
entry.runtime_data = coordinator
@ -36,6 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> boo
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."""
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(
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(
@ -101,6 +107,29 @@ class BringConfigFlow(ConfigFlow, domain=DOMAIN):
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]:
"""Auth Helper."""

View File

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

View File

@ -6,7 +6,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
from . import BringConfigEntry
from .coordinator import BringConfigEntry
async def async_get_config_entry_diagnostics(
@ -14,4 +14,8 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""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 bring_api.types import BringList
from bring_api import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
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": {
"event": {
"activity": {
"default": "mdi:bell"
}
},
"sensor": {
"urgent": {
"default": "mdi:run-fast"

View File

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

View File

@ -7,7 +7,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: todo
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: todo
@ -58,9 +58,9 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
@ -69,4 +69,4 @@ rules:
# Platinum
async-dependency: 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 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.types import BringList
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -20,8 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BringConfigEntry
from .coordinator import BringData, BringDataUpdateCoordinator
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
from .util import list_language, sum_attributes

View File

@ -5,9 +5,15 @@
"config": {
"step": {
"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": {
"email": "[%key:common::config_flow::data::email%]",
"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": {
@ -16,21 +22,53 @@
"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%]"
}
},
"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": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
"unknown": "[%key:common::config_flow::error::unknown%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"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": {
"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": {
"urgent": {
"name": "Urgent",

View File

@ -9,10 +9,10 @@ import uuid
from bring_api import (
BringItem,
BringItemOperation,
BringList,
BringNotificationType,
BringRequestException,
)
from bring_api.types import BringList
import voluptuous as vol
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.entity_platform import AddEntitiesCallback
from . import BringConfigEntry
from .const import (
ATTR_ITEM_NAME,
ATTR_NOTIFICATION_TYPE,
DOMAIN,
SERVICE_PUSH_NOTIFICATION,
)
from .coordinator import BringData, BringDataUpdateCoordinator
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
from .entity import BringBaseEntity
PARALLEL_UPDATES = 0

View File

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

View File

@ -8,16 +8,12 @@ from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import hashlib
import logging
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.cloud_api import (
async_files_delete_file,
async_files_download_details,
async_files_list,
async_files_upload_details,
)
from hass_nabucasa.api import CloudApiNonRetryableError
from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
from homeassistant.core import HomeAssistant, callback
@ -28,7 +24,7 @@ from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
_STORAGE_BACKUP = "backup"
_STORAGE_BACKUP: Literal["backup"] = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
@ -109,63 +105,14 @@ class CloudBackupAgent(BackupAgent):
raise BackupAgentError("Backup not found")
try:
details = await async_files_download_details(
self._cloud,
content = await self._cloud.files.download(
storage_type=_STORAGE_BACKUP,
filename=self._get_backup_filename(),
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to get download details") from err
except CloudError as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err
try:
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
return ChunkAsyncStreamIterator(content)
async def async_upload_backup(
self,
@ -190,7 +137,8 @@ class CloudBackupAgent(BackupAgent):
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._async_do_upload_backup(
await self._cloud.files.upload(
storage_type=_STORAGE_BACKUP,
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
@ -198,9 +146,19 @@ class CloudBackupAgent(BackupAgent):
size=size,
)
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:
raise
raise BackupAgentError(f"Failed to upload backup {err}") from err
tries += 1
retry_timer = random.randint(_RETRY_SECONDS_MIN, _RETRY_SECONDS_MAX)
_LOGGER.info(

View File

@ -17,6 +17,11 @@
"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": {
"deprecated_gender": {
"title": "The {deprecated_option} text-to-speech option is deprecated",

View File

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

View File

@ -2,19 +2,19 @@
"config": {
"step": {
"user": {
"title": "Coinbase API Key Details",
"title": "Coinbase API key details",
"description": "Please enter the details of your API key as provided by Coinbase.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"api_token": "API Secret"
"api_token": "API secret"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"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_secret": "API credentials rejected by Coinbase due to an invalid API Secret.",
"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.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@ -24,12 +24,12 @@
"options": {
"step": {
"init": {
"description": "Adjust Coinbase Options",
"description": "Adjust Coinbase options",
"data": {
"account_balance_currencies": "Wallet balances to report.",
"exchange_rate_currencies": "Exchange rates to report.",
"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:
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:
history.async_add_user_content(UserContent(content=user_input.text))
@ -63,6 +56,15 @@ def async_get_chat_log(
)
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

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"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",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
"requirements": ["eheimdigital==1.0.5"],
"requirements": ["eheimdigital==1.0.6"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]

View File

@ -4,12 +4,16 @@ from __future__ import annotations
import aiohttp
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.core import HomeAssistant
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 .coordinator import (
@ -44,7 +48,9 @@ async def async_setup_entry(
raise ConfigEntryNotReady from err
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)
account_coordinator = ElectricKiwiAccountDataCoordinator(hass, entry, ek_api)
@ -53,6 +59,8 @@ async def async_setup_entry(
await ek_api.set_active_session()
await hop_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:
raise ConfigEntryNotReady from err
@ -70,3 +78,53 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
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 typing import cast
from aiohttp import ClientSession
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
class AsyncConfigEntryAuth(AbstractAuth):
class ConfigEntryElectricKiwiAuth(AbstractAuth):
"""Provide Electric Kiwi authentication tied to an OAuth2 based config entry."""
def __init__(
@ -29,4 +28,21 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token."""
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
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 . import api
from .const import DOMAIN, SCOPE_VALUES
@ -17,6 +22,8 @@ class ElectricKiwiOauth2FlowHandler(
):
"""Config flow to handle Electric Kiwi OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@ -40,12 +47,30 @@ class ElectricKiwiOauth2FlowHandler(
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
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()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create an entry for Electric Kiwi."""
existing_entry = await self.async_set_unique_id(DOMAIN)
if existing_entry:
return self.async_update_reload_and_abort(existing_entry, data=data)
return await super().async_oauth_create_entry(data)
ek_api = ElectricKiwiApi(
api.ConfigFlowElectricKiwiAuth(self.hass, data["token"]["access_token"])
)
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"
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.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.core import HomeAssistant
@ -34,7 +34,7 @@ class ElectricKiwiRuntimeData:
type ElectricKiwiConfigEntry = ConfigEntry[ElectricKiwiRuntimeData]
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountSummary]):
"""ElectricKiwi Account Data object."""
def __init__(
@ -51,13 +51,13 @@ class ElectricKiwiAccountDataCoordinator(DataUpdateCoordinator[AccountBalance]):
name="Electric Kiwi Account Data",
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."""
try:
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:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
@ -85,7 +85,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
# Polling interval. Will only be polled if there are subscribers.
update_interval=HOP_SCAN_INTERVAL,
)
self._ek_api = ek_api
self.ek_api = ek_api
self.hop_intervals: HopIntervals | None = None
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:
"""Update selected hop and data."""
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:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:
@ -118,7 +118,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
try:
async with asyncio.timeout(60):
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(
filter(
lambda pair: pair[1].active == 1,
@ -127,7 +127,7 @@ class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]):
)
self.hop_intervals = hop_intervals
return await self._ek_api.get_hop()
return await self.ek_api.get_hop()
except AuthException as auth_err:
raise ConfigEntryAuthFailed from auth_err
except ApiException as api_err:

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub",
"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."""
super().__init__(coordinator)
self._attr_unique_id = (
f"{coordinator._ek_api.customer_number}" # noqa: SLF001
f"_{coordinator._ek_api.connection_id}_{description.key}" # noqa: SLF001
f"{coordinator.ek_api.customer_number}"
f"_{coordinator.ek_api.electricity.identifier}_{description.key}"
)
self.entity_description = description
self.values_dict = coordinator.get_hop_options()

View File

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

View File

@ -21,7 +21,8 @@
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"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": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -3,29 +3,16 @@
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.const import 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 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)
_LOGGER = logging.getLogger(__name__)
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:
return False
async def async_update_data():
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,
)
coordinator = FireServiceUpdateCoordinator(hass, client, entry)
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."""
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)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
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.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FireServiceRotaClient
from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
async def async_setup_entry(
@ -26,14 +23,16 @@ async def async_setup_entry(
DATA_CLIENT
]
coordinator: DataUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][
coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][
entry.entry_id
][DATA_COORDINATOR]
async_add_entities([ResponseBinarySensor(coordinator, client, entry)])
class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity):
class ResponseBinarySensor(
CoordinatorEntity[FireServiceUpdateCoordinator], BinarySensorEntity
):
"""Representation of an FireServiceRota sensor."""
_attr_has_entity_name = True
@ -41,7 +40,7 @@ class ResponseBinarySensor(CoordinatorEntity, BinarySensorEntity):
def __init__(
self,
coordinator: DataUpdateCoordinator,
coordinator: FireServiceUpdateCoordinator,
client: FireServiceRotaClient,
entry: ConfigEntry,
) -> 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",
"iot_class": "cloud_polling",
"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 . import api
from .const import DOMAIN, FitbitScope
from .const import FitbitScope
from .coordinator import FitbitData, FitbitDeviceCoordinator
from .exceptions import FitbitApiException, FitbitAuthException
from .model import config_from_entry_data
@ -15,10 +15,11 @@ from .model import config_from_entry_data
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up fitbit from a config entry."""
hass.data.setdefault(DOMAIN, {})
type FitbitConfigEntry = ConfigEntry[FitbitData]
async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool:
"""Set up fitbit from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
@ -41,18 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinator = FitbitDeviceCoordinator(hass, fitbit_api)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = FitbitData(
api=fitbit_api, device_coordinator=coordinator
)
entry.runtime_data = FitbitData(api=fitbit_api, device_coordinator=coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

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

View File

@ -2,7 +2,6 @@
from libpyfoscam import FoscamCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
@ -14,13 +13,13 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from .config_flow import DEFAULT_RTSP_PORT
from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
from .coordinator import FoscamCoordinator
from .const import CONF_RTSP_PORT, LOGGER
from .coordinator import FoscamConfigEntry, FoscamCoordinator
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."""
session = FoscamCamera(
@ -30,11 +29,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data[CONF_PASSWORD],
verbose=False,
)
coordinator = FoscamCoordinator(hass, session)
coordinator = FoscamCoordinator(hass, entry, session)
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
await async_migrate_entities(hass, entry)
@ -44,20 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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_ok = 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
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: FoscamConfigEntry) -> bool:
"""Migrate old entry."""
LOGGER.debug("Migrating from version %s", entry.version)
@ -97,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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."""
@callback

View File

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

View File

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

View File

@ -5,24 +5,23 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import FoscamCoordinator
from .const import DOMAIN, LOGGER
from .const import LOGGER
from .coordinator import FoscamConfigEntry, FoscamCoordinator
from .entity import FoscamEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: FoscamConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""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()
@ -36,7 +35,7 @@ class FoscamSleepSwitch(FoscamEntity, SwitchEntity):
def __init__(
self,
coordinator: FoscamCoordinator,
config_entry: ConfigEntry,
config_entry: FoscamConfigEntry,
) -> None:
"""Initialize a Foscam Sleep Switch."""
super().__init__(coordinator, config_entry.entry_id)

View File

@ -2,17 +2,12 @@
from __future__ import annotations
import logging
from typing import Final
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
PLATFORMS: Final[list[Platform]] = [
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."""
hass.data.setdefault(DOMAIN, {})
api_key = entry.data[CONF_API_KEY]
coordinator = FreedomproDataUpdateCoordinator(hass, api_key)
coordinator = FreedomproDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
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)
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_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
async def update_listener(
hass: HomeAssistant, config_entry: FreedomproConfigEntry
) -> None:
"""Update listener."""
await hass.config_entries.async_reload(config_entry.entry_id)

View File

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

View File

@ -15,7 +15,6 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
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 .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator
from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@ -44,11 +43,13 @@ SUPPORTED_HVAC_MODES = [
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Freedompro climate."""
api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
Device(
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 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.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -15,18 +18,27 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FreedomproConfigEntry = ConfigEntry[FreedomproDataUpdateCoordinator]
class FreedomproDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Class to manage fetching Freedompro data API."""
def __init__(self, hass, api_key):
def __init__(self, hass: HomeAssistant, entry: FreedomproConfigEntry) -> None:
"""Initialize."""
self._hass = hass
self._api_key = api_key
self._api_key = entry.data[CONF_API_KEY]
self._devices: list[dict[str, Any]] | None = None
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):
if self._devices is None:

View File

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

View File

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

View File

@ -13,7 +13,6 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
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 .const import DOMAIN
from .coordinator import FreedomproDataUpdateCoordinator
from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant,
entry: FreedomproConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Freedompro light."""
api_key: str = entry.data[CONF_API_KEY]
coordinator: FreedomproDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
Device(hass, api_key, device, coordinator)
for device in coordinator.data

View File

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

View File

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

View File

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

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"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",
"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_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-1.5-flash-latest"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
CONF_TOP_P = "top_p"

View File

@ -38,6 +38,10 @@
"local_name": "GV5126*",
"connectable": false
},
{
"local_name": "GV5179*",
"connectable": false
},
{
"local_name": "GVH5127*",
"connectable": false
@ -131,5 +135,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"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
from typing import Any
from govee_local_api import GoveeDevice, GoveeLightCapability
from govee_local_api import GoveeDevice, GoveeLightFeatures
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@ -71,13 +71,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
capabilities = device.capabilities
color_modes = {ColorMode.ONOFF}
if capabilities:
if GoveeLightCapability.COLOR_RGB in capabilities:
if GoveeLightFeatures.COLOR_RGB & capabilities.features:
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)
self._attr_max_color_temp_kelvin = 9000
self._attr_min_color_temp_kelvin = 2000
if GoveeLightCapability.BRIGHTNESS in capabilities:
if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
color_modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)

View File

@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"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": {
"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 datetime import datetime
import logging
from typing import Any
from gps3.agps3threaded import AGPS3mechanism
@ -14,6 +13,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
ATTR_LATITUDE,
@ -37,14 +37,32 @@ _LOGGER = logging.getLogger(__name__)
ATTR_CLIMB = "climb"
ATTR_ELEVATION = "elevation"
ATTR_GPS_TIME = "gps_time"
ATTR_SPEED = "speed"
ATTR_TOTAL_SATELLITES = "total_satellites"
ATTR_USED_SATELLITES = "used_satellites"
DEFAULT_NAME = "GPS"
_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)
class GpsdSensorDescription(SensorEntityDescription):
"""Class describing GPSD sensor entities."""
@ -116,6 +134,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = (
suggested_display_precision=2,
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."""
value = self.entity_description.value_fn(self.agps_thread)
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%]" }
}
},
"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",
"iot_class": "cloud_polling",
"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 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
hass.services.async_register(

View File

@ -20,6 +20,7 @@ from aiohasupervisor.models import (
backups as supervisor_backups,
mounts as supervisor_mounts,
)
from aiohasupervisor.models.backups import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL_STORAGE
from homeassistant.components.backup import (
DATA_MANAGER,
@ -56,8 +57,6 @@ from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
LOCATION_CLOUD_BACKUP = ".cloud_backup"
LOCATION_LOCAL = ".local"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
# Set on backups automatically created when updating an addon
@ -72,7 +71,9 @@ async def async_get_backup_agents(
"""Return the hassio backup agents."""
client = get_supervisor_client(hass)
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:
if mount.usage is not supervisor_mounts.MountUsage.BACKUP:
continue
@ -112,7 +113,7 @@ def async_register_backup_agents_listener(
def _backup_details_to_agent_backup(
details: supervisor_backups.BackupComplete, location: str | None
details: supervisor_backups.BackupComplete, location: str
) -> AgentBackup:
"""Convert a supervisor backup details object to an agent backup."""
homeassistant_included = details.homeassistant is not None
@ -125,7 +126,6 @@ def _backup_details_to_agent_backup(
for addon in details.addons
]
extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL
return AgentBackup(
addons=addons,
backup_id=details.slug,
@ -148,7 +148,7 @@ class SupervisorBackupAgent(BackupAgent):
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."""
super().__init__()
self._hass = hass
@ -206,7 +206,7 @@ class SupervisorBackupAgent(BackupAgent):
backup_list = await self._client.backups.list()
result = []
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
details = await self._client.backups.backup_info(backup.slug)
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)
except SupervisorNotFoundError:
return None
if self.location not in details.locations:
if self.location not in details.location_attributes:
return None
return _backup_details_to_agent_backup(details, self.location)
@ -295,8 +295,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
# will be handled by async_upload_backup.
# 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.
encrypted_locations: list[str | None] = []
decrypted_locations: list[str | None] = []
encrypted_locations: list[str] = []
decrypted_locations: list[str] = []
agents_settings = manager.config.data.agents
for hassio_agent in hassio_agents:
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
)
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(
self,
backup: supervisor_backups.NewBackup,
locations: list[str | None],
locations: list[str],
*,
on_progress: Callable[[CreateBackupEvent], None],
remove_after_upload: bool,
@ -508,7 +508,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
else None
)
restore_location: str | None
restore_location: str
if manager.backup_agents[agent_id].domain != DOMAIN:
# Download the backup to the supervisor. Supervisor will clean up the backup
# two days after the restore is done.
@ -577,10 +577,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
) -> None:
"""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")
return
restore_job_id = UUID(restore_job_str)
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
sent_event = False
@ -634,7 +635,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
@callback
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]:
"""Listen for job events."""
@ -649,7 +650,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
if (
data.get("event") != "job"
or not (event_data := data.get("data"))
or event_data.get("uuid") != job_id
or event_data.get("uuid") != job_id.hex
):
return
on_event(event_data)
@ -660,10 +661,10 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
return unsub
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:
"""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)
on_event(job.to_dict())

View File

@ -295,6 +295,8 @@ def async_remove_addons_from_dev_reg(
class HassioDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to retrieve Hass.io status."""
config_entry: ConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry
) -> None:
@ -302,6 +304,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=HASSIO_UPDATE_INTERVAL,
# 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",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.2.2b6"],
"requirements": ["aiohasupervisor==0.3.0"],
"single_config_entry": true
}

View File

@ -82,12 +82,20 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
try:
await self.heos.connect()
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
try:
await self.heos.get_players()
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:
_LOGGER.warning(

View File

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

View File

@ -112,6 +112,12 @@
"not_heos_media_player": {
"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": {
"message": "Unknown source: {source}"
}

View File

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

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"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.typing import ConfigType
DOMAIN = "homeassistant_hardware"
from .const import DATA_COMPONENT, DOMAIN
from .helpers import HardwareInfoDispatcher
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
hass.data[DATA_COMPONENT] = HardwareInfoDispatcher(hass)
return True

View File

@ -1,10 +1,23 @@
"""Constants for the Homeassistant Hardware integration."""
from __future__ import annotations
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__)
DOMAIN = "homeassistant_hardware"
DATA_COMPONENT: HassKey[HardwareInfoDispatcher] = HassKey(DOMAIN)
ZHA_DOMAIN = "zha"
OTBR_DOMAIN = "otbr"
OTBR_ADDON_NAME = "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 . import silabs_multiprotocol_addon
from .const import ZHA_DOMAIN
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager,
get_zha_device_path,
get_zigbee_flasher_addon_manager,
guess_hardware_owners,
probe_silabs_firmware_type,
)
@ -519,19 +521,15 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
assert self._device is not None
owners = await guess_hardware_owners(self.hass, self._device)
if is_hassio(self.hass):
otbr_manager = get_otbr_addon_manager(self.hass)
otbr_addon_info = await self._async_get_addon_info(otbr_manager)
if (
otbr_addon_info.state != AddonState.NOT_INSTALLED
and otbr_addon_info.options.get("device") == self._device
):
raise AbortFlow(
"otbr_still_using_stick",
description_placeholders=self._get_translation_placeholders(),
)
for info in owners:
for owner in info.owners:
if info.source == OTBR_DOMAIN and isinstance(owner, OwningAddon):
raise AbortFlow(
"otbr_still_using_stick",
description_placeholders=self._get_translation_placeholders(),
)
return await super().async_step_pick_firmware_zigbee(user_input)
@ -541,15 +539,14 @@ class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Pick Thread firmware."""
assert self._device is not None
for zha_entry in self.hass.config_entries.async_entries(
ZHA_DOMAIN,
include_ignore=False,
include_disabled=True,
):
if get_zha_device_path(zha_entry) == self._device:
raise AbortFlow(
"zha_still_using_stick",
description_placeholders=self._get_translation_placeholders(),
)
owners = await guess_hardware_owners(self.hass, self._device)
for info in owners:
for owner in info.owners:
if info.source == ZHA_DOMAIN and isinstance(owner, OwningIntegration):
raise AbortFlow(
"zha_still_using_stick",
description_placeholders=self._get_translation_placeholders(),
)
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
import asyncio
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass
from enum import StrEnum
import logging
from typing import cast
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.flasher import Flasher
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.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from . import DATA_COMPONENT
from .const import (
OTBR_ADDON_MANAGER_DATA,
OTBR_ADDON_NAME,
OTBR_ADDON_SLUG,
ZHA_DOMAIN,
ZIGBEE_FLASHER_ADDON_MANAGER_DATA,
ZIGBEE_FLASHER_ADDON_NAME,
ZIGBEE_FLASHER_ADDON_SLUG,
@ -55,11 +55,6 @@ class ApplicationType(StrEnum):
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)
@callback
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)
class FirmwareGuess:
@dataclass(kw_only=True)
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."""
is_running: bool
device: str
firmware_type: ApplicationType
firmware_version: str | None
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:
"""Guess the firmware type based on installed addons and other integrations."""
device_guesses: defaultdict[str | None, list[FirmwareGuess]] = defaultdict(list)
async def guess_hardware_owners(
hass: HomeAssistant, device_path: str
) -> 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):
zha_path = get_zha_device_path(zha_config_entry)
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",
)
)
async for firmware_info in hass.data[DATA_COMPONENT].iter_firmware_info():
device_guesses[firmware_info.device].append(firmware_info)
# It may be possible for the OTBR addon to be present without the integration
if is_hassio(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:
if otbr_addon_info.state != AddonState.NOT_INSTALLED:
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)
try:
@ -136,30 +188,48 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
else:
if multipan_addon_info.state != AddonState.NOT_INSTALLED:
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 device_path not in device_guesses:
return FirmwareGuess(
is_running=False, firmware_type=ApplicationType.EZSP, source="unknown"
if multipan_path is not None:
device_guesses[multipan_path].append(
FirmwareInfo(
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
# the sort order we defined above
guesses = sorted(
device_guesses[device_path],
key=lambda guess: guess.is_running,
)
# Prioritize guesses that are pulled from a real source
guesses = [
(guess, sum([await owner.is_running(hass) for owner in guess.owners]))
for guess in hardware_owners
]
guesses.sort(key=lambda p: p[1])
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(

View File

@ -4,7 +4,7 @@ from __future__ import annotations
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.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
# 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!
firmware_guess = await guess_firmware_type(
firmware_guess = await guess_firmware_info(
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 (
ApplicationType,
guess_firmware_type,
guess_firmware_info,
)
from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry
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
# 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!
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[FIRMWARE] = firmware_guess.firmware_type.value

View File

@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
ATTR_VIA_DEVICE,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
@ -137,6 +138,21 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
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(
key="total_power_import_kwh",
translation_key="total_energy_import_kwh",

View File

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

View File

@ -5,7 +5,7 @@
"title": "Connect to the PowerView Hub",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"api_version": "Hub Generation"
"api_version": "Hub generation"
},
"data_description": {
"api_version": "API version is detectable, but you can override and force a specific version"
@ -19,7 +19,7 @@
"flow_title": "{name} ({host})",
"error": {
"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%]"
},
"abort": {

View File

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

View File

@ -2,7 +2,7 @@
"services": {
"request_data": {
"name": "Request data",
"description": "Requesta new data from the charging station."
"description": "Requests new data from the charging station."
},
"authorize": {
"name": "Authorize",
@ -46,7 +46,7 @@
"fields": {
"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": {
"name": "Failsafe fallback",
@ -54,7 +54,7 @@
},
"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",
"iot_class": "cloud_polling",
"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:
"""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:
return None
value = field_data["values"][-1]["s"]
@ -178,7 +178,7 @@ async def async_setup_entry(
continue
# 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(
sensor.data[field].get("unit")
)
@ -240,7 +240,9 @@ class LaCrosseViewSensor(
@property
def available(self) -> bool:
"""Return True if entity is available."""
data = self.coordinator.data[self.index].data
return (
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": {
"default": "mdi:snowflake-variant"
},
"express_fridge": {
"default": "mdi:snowflake"
},
"hot_water_mode": {
"default": "mdi:list-status"
"default": "mdi:heat-wave"
},
"humidity_warm_mode": {
"default": "mdi:heat-wave"
@ -39,6 +42,9 @@
},
"warm_mode": {
"default": "mdi:heat-wave"
},
"display_light": {
"default": "mdi:lightbulb-on-outline"
}
},
"binary_sensor": {

View File

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

View File

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

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