Merge branch 'dev' into dev

This commit is contained in:
luan-nvg 2025-02-05 19:24:51 -03:00 committed by GitHub
commit 36593c3a38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
235 changed files with 5749 additions and 1473 deletions

View File

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

View File

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

2
CODEOWNERS generated
View File

@ -731,6 +731,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam /homeassistant/components/intent/ @home-assistant/core @synesthesiam
/tests/components/intent/ @home-assistant/core @synesthesiam /tests/components/intent/ @home-assistant/core @synesthesiam
/homeassistant/components/intesishome/ @jnimmo /homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe
/homeassistant/components/ios/ @robbiet480 /homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480 /tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard /homeassistant/components/iotawatt/ @gtdiehl @jyavenard

View File

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

View File

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

View File

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

View File

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

View File

@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet", "documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aranet4==2.5.0"] "requirements": ["aranet4==2.5.1"]
} }

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"] "requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
} }

View File

@ -37,7 +37,7 @@ from .manager import (
RestoreBackupState, RestoreBackupState,
WrittenBackup, WrittenBackup,
) )
from .models import AddonInfo, AgentBackup, Folder from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .util import suggested_filename, suggested_filename_from_name_date from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers from .websocket import async_register_websocket_handlers
@ -48,6 +48,7 @@ __all__ = [
"BackupAgentError", "BackupAgentError",
"BackupAgentPlatformProtocol", "BackupAgentPlatformProtocol",
"BackupManagerError", "BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol", "BackupPlatformProtocol",
"BackupReaderWriter", "BackupReaderWriter",
"BackupReaderWriterError", "BackupReaderWriterError",

View File

@ -11,13 +11,7 @@ from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupError from .models import AgentBackup, BackupAgentError
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupAgentUnreachableError(BackupAgentError): class BackupAgentUnreachableError(BackupAgentError):
@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError):
_message = "The backup agent is unreachable." _message = "The backup agent is unreachable."
class BackupNotFound(BackupAgentError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"
class BackupAgent(abc.ABC): class BackupAgent(abc.ABC):
"""Backup agent interface.""" """Backup agent interface."""

View File

@ -11,9 +11,9 @@ from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .models import AgentBackup from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename from .util import read_backup, suggested_filename

View File

@ -21,6 +21,7 @@ from . import util
from .agent import BackupAgent from .agent import BackupAgent
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BackupManager from .manager import BackupManager
from .models import BackupNotFound
@callback @callback
@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
} }
if not password or not backup.protected: try:
return await self._send_backup_no_password( if not password or not backup.protected:
request, headers, backup_id, agent_id, agent, manager return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
) )
return await self._send_backup_with_password( except BackupNotFound:
hass, request, headers, backup_id, agent_id, password, agent, manager return Response(status=HTTPStatus.NOT_FOUND)
)
async def _send_backup_no_password( async def _send_backup_no_password(
self, self,

View File

@ -9,6 +9,7 @@ from dataclasses import dataclass, replace
from enum import StrEnum from enum import StrEnum
import hashlib import hashlib
import io import io
from itertools import chain
import json import json
from pathlib import Path, PurePath from pathlib import Path, PurePath
import shutil import shutil
@ -50,7 +51,14 @@ from .const import (
EXCLUDE_FROM_BACKUP, EXCLUDE_FROM_BACKUP,
LOGGER, LOGGER,
) )
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .store import BackupStore from .store import BackupStore
from .util import ( from .util import (
AsyncIteratorReader, AsyncIteratorReader,
@ -274,12 +282,6 @@ class BackupReaderWriter(abc.ABC):
"""Get restore events after core restart.""" """Get restore events after core restart."""
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class IncorrectPasswordError(BackupReaderWriterError): class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect.""" """Raised when the password is incorrect."""
@ -826,7 +828,7 @@ class BackupManager:
password=None, password=None,
) )
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors) self.known_backups.add(written_backup.backup, agent_errors, [])
return written_backup.backup.backup_id return written_backup.backup.backup_id
async def async_create_backup( async def async_create_backup(
@ -950,12 +952,23 @@ class BackupManager:
with_automatic_settings: bool, with_automatic_settings: bool,
) -> NewBackup: ) -> NewBackup:
"""Initiate generating a backup.""" """Initiate generating a backup."""
if not agent_ids: unavailable_agents = [
raise BackupManagerError("At least one agent must be selected")
if invalid_agents := [
agent_id for agent_id in agent_ids if agent_id not in self.backup_agents agent_id for agent_id in agent_ids if agent_id not in self.backup_agents
]: ]
raise BackupManagerError(f"Invalid agents selected: {invalid_agents}") if not (
available_agents := [
agent_id for agent_id in agent_ids if agent_id in self.backup_agents
]
):
raise BackupManagerError(
f"At least one available backup agent must be selected, got {agent_ids}"
)
if unavailable_agents:
LOGGER.warning(
"Backup agents %s are not available, will backupp to %s",
unavailable_agents,
available_agents,
)
if include_all_addons and include_addons: if include_all_addons and include_addons:
raise BackupManagerError( raise BackupManagerError(
"Cannot include all addons and specify specific addons" "Cannot include all addons and specify specific addons"
@ -972,7 +985,7 @@ class BackupManager:
new_backup, new_backup,
self._backup_task, self._backup_task,
) = await self._reader_writer.async_create_backup( ) = await self._reader_writer.async_create_backup(
agent_ids=agent_ids, agent_ids=available_agents,
backup_name=backup_name, backup_name=backup_name,
extra_metadata=extra_metadata extra_metadata=extra_metadata
| { | {
@ -991,7 +1004,9 @@ class BackupManager:
raise BackupManagerError(str(err)) from err raise BackupManagerError(str(err)) from err
backup_finish_task = self._backup_finish_task = self.hass.async_create_task( backup_finish_task = self._backup_finish_task = self.hass.async_create_task(
self._async_finish_backup(agent_ids, with_automatic_settings, password), self._async_finish_backup(
available_agents, unavailable_agents, with_automatic_settings, password
),
name="backup_manager_finish_backup", name="backup_manager_finish_backup",
) )
if not raise_task_error: if not raise_task_error:
@ -1008,7 +1023,11 @@ class BackupManager:
return new_backup return new_backup
async def _async_finish_backup( async def _async_finish_backup(
self, agent_ids: list[str], with_automatic_settings: bool, password: str | None self,
available_agents: list[str],
unavailable_agents: list[str],
with_automatic_settings: bool,
password: str | None,
) -> None: ) -> None:
"""Finish a backup.""" """Finish a backup."""
if TYPE_CHECKING: if TYPE_CHECKING:
@ -1027,7 +1046,7 @@ class BackupManager:
LOGGER.debug( LOGGER.debug(
"Generated new backup with backup_id %s, uploading to agents %s", "Generated new backup with backup_id %s, uploading to agents %s",
written_backup.backup.backup_id, written_backup.backup.backup_id,
agent_ids, available_agents,
) )
self.async_on_backup_event( self.async_on_backup_event(
CreateBackupEvent( CreateBackupEvent(
@ -1040,13 +1059,15 @@ class BackupManager:
try: try:
agent_errors = await self._async_upload_backup( agent_errors = await self._async_upload_backup(
backup=written_backup.backup, backup=written_backup.backup,
agent_ids=agent_ids, agent_ids=available_agents,
open_stream=written_backup.open_stream, open_stream=written_backup.open_stream,
password=password, password=password,
) )
finally: finally:
await written_backup.release_stream() await written_backup.release_stream()
self.known_backups.add(written_backup.backup, agent_errors) self.known_backups.add(
written_backup.backup, agent_errors, unavailable_agents
)
if not agent_errors: if not agent_errors:
if with_automatic_settings: if with_automatic_settings:
# create backup was successful, update last_completed_automatic_backup # create backup was successful, update last_completed_automatic_backup
@ -1055,7 +1076,7 @@ class BackupManager:
backup_success = True backup_success = True
if with_automatic_settings: if with_automatic_settings:
self._update_issue_after_agent_upload(agent_errors) self._update_issue_after_agent_upload(agent_errors, unavailable_agents)
# delete old backups more numerous than copies # delete old backups more numerous than copies
# try this regardless of agent errors above # try this regardless of agent errors above
await delete_backups_exceeding_configured_count(self) await delete_backups_exceeding_configured_count(self)
@ -1215,10 +1236,10 @@ class BackupManager:
) )
def _update_issue_after_agent_upload( def _update_issue_after_agent_upload(
self, agent_errors: dict[str, Exception] self, agent_errors: dict[str, Exception], unavailable_agents: list[str]
) -> None: ) -> None:
"""Update issue registry after a backup is uploaded to agents.""" """Update issue registry after a backup is uploaded to agents."""
if not agent_errors: if not agent_errors and not unavailable_agents:
ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed") ir.async_delete_issue(self.hass, DOMAIN, "automatic_backup_failed")
return return
ir.async_create_issue( ir.async_create_issue(
@ -1232,7 +1253,13 @@ class BackupManager:
translation_key="automatic_backup_failed_upload_agents", translation_key="automatic_backup_failed_upload_agents",
translation_placeholders={ translation_placeholders={
"failed_agents": ", ".join( "failed_agents": ", ".join(
self.backup_agents[agent_id].name for agent_id in agent_errors chain(
(
self.backup_agents[agent_id].name
for agent_id in agent_errors
),
unavailable_agents,
)
) )
}, },
) )
@ -1301,11 +1328,12 @@ class KnownBackups:
self, self,
backup: AgentBackup, backup: AgentBackup,
agent_errors: dict[str, Exception], agent_errors: dict[str, Exception],
unavailable_agents: list[str],
) -> None: ) -> None:
"""Add a backup.""" """Add a backup."""
self._backups[backup.backup_id] = KnownBackup( self._backups[backup.backup_id] = KnownBackup(
backup_id=backup.backup_id, backup_id=backup.backup_id,
failed_agent_ids=list(agent_errors), failed_agent_ids=list(chain(agent_errors, unavailable_agents)),
) )
self._manager.store.save() self._manager.store.save()
@ -1411,7 +1439,11 @@ class CoreBackupReaderWriter(BackupReaderWriter):
manager = self._hass.data[DATA_MANAGER] manager = self._hass.data[DATA_MANAGER]
agent_config = manager.config.data.agents.get(self._local_agent_id) agent_config = manager.config.data.agents.get(self._local_agent_id)
if agent_config and not agent_config.protected: if (
self._local_agent_id in agent_ids
and agent_config
and not agent_config.protected
):
password = None password = None
backup = AgentBackup( backup = AgentBackup(

View File

@ -77,7 +77,25 @@ class BackupError(HomeAssistantError):
error_code = "unknown" error_code = "unknown"
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupManagerError(BackupError): class BackupManagerError(BackupError):
"""Backup manager error.""" """Backup manager error."""
error_code = "backup_manager_error" error_code = "backup_manager_error"
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"

View File

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

View File

@ -15,7 +15,7 @@ from .manager import (
IncorrectPasswordError, IncorrectPasswordError,
ManagerStateEvent, ManagerStateEvent,
) )
from .models import Folder from .models import BackupNotFound, Folder
@callback @callback
@ -151,6 +151,8 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"), restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"], restore_homeassistant=msg["restore_homeassistant"],
) )
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError: except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password") connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else: else:
@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
agent_id=msg["agent_id"], agent_id=msg["agent_id"],
password=msg.get("password"), password=msg.get("password"),
) )
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError: except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password") connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported: except DecryptOnDowloadNotSupported:

View File

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

View File

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

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import datetime import datetime
import logging import logging
import platform import platform
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from bleak_retry_connector import BleakSlotManager from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import ( from bluetooth_adapters import (
@ -302,7 +302,6 @@ async def async_update_device(
entry: ConfigEntry, entry: ConfigEntry,
adapter: str, adapter: str,
details: AdapterDetails, details: AdapterDetails,
via_device_domain: str | None = None,
via_device_id: str | None = None, via_device_id: str | None = None,
) -> None: ) -> None:
"""Update device registry entry. """Update device registry entry.
@ -322,10 +321,11 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_VERSION), sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_VERSION), hw_version=details.get(ADAPTER_HW_VERSION),
) )
if via_device_id: if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)):
device_registry.async_update_device( kwargs: dict[str, Any] = {"via_device_id": via_device_id}
device_entry.id, via_device_id=via_device_id if not device_entry.area_id and via_device_entry.area_id:
) kwargs["area_id"] = via_device_entry.area_id
device_registry.async_update_device(device_entry.id, **kwargs)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -360,7 +360,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, entry,
source_entry.title, source_entry.title,
details, details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID), entry.data.get(CONF_SOURCE_DEVICE_ID),
) )
return True return True

View File

@ -140,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
title=adapter_title(adapter, details), data={} title=adapter_title(adapter, details), data={}
) )
configured_addresses = self._async_current_ids() configured_addresses = self._async_current_ids(include_ignore=False)
bluetooth_adapters = get_adapters() bluetooth_adapters = get_adapters()
await bluetooth_adapters.refresh() await bluetooth_adapters.refresh()
self._adapters = bluetooth_adapters.adapters self._adapters = bluetooth_adapters.adapters
@ -155,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS) and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
] ]
if not unconfigured_adapters: if not unconfigured_adapters:
ignored_adapters = len(
self._async_current_entries(include_ignore=True)
) - len(self._async_current_entries(include_ignore=False))
return self.async_abort( return self.async_abort(
reason="no_adapters", reason="no_adapters",
description_placeholders={"ignored_adapters": str(ignored_adapters)},
) )
if len(unconfigured_adapters) == 1: if len(unconfigured_adapters) == 1:
self._adapter = list(self._adapters)[0] self._adapter = list(self._adapters)[0]

View File

@ -16,11 +16,11 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"bleak==0.22.3", "bleak==0.22.3",
"bleak-retry-connector==3.8.0", "bleak-retry-connector==3.8.1",
"bluetooth-adapters==0.21.1", "bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.2", "bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.23.3", "bluetooth-data-tools==1.23.4",
"dbus-fast==2.32.0", "dbus-fast==2.33.0",
"habluetooth==3.21.0" "habluetooth==3.21.1"
] ]
} }

View File

@ -23,7 +23,7 @@
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters." "no_adapters": "No unconfigured Bluetooth adapters found."
} }
}, },
"options": { "options": {

View File

@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome", "documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bthome-ble==3.12.3"] "requirements": ["bthome-ble==3.12.4"]
} }

View File

@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
hass.http.register_view(CloudRegisterView) hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView) hass.http.register_view(CloudForgotPasswordView)
hass.http.register_view(DownloadSupportPackageView)
_CLOUD_ERRORS.update( _CLOUD_ERRORS.update(
{ {
@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
return self.json_message("ok") return self.json_message("ok")
class DownloadSupportPackageView(HomeAssistantView):
"""Download support package view."""
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
def _generate_markdown(
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
markdown = ""
first = True
for key, value in domain_info.items():
markdown += f"{key} | {value}\n"
if first:
markdown += "--- | ---\n"
first = False
return markdown + "\n"
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
f"<details><summary>{domain}</summary>\n\n"
f"{domain_info_md}"
"</details>\n\n"
)
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""
hass = request.app[KEY_HASS]
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
markdown = self._generate_markdown(hass_info, domain_health)
return web.Response(
body=markdown,
content_type="text/markdown",
headers={
"Content-Disposition": 'attachment; filename="support_package.md"'
},
)
@websocket_api.require_admin @websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"}) @websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
@websocket_api.async_response @websocket_api.async_response

View File

@ -302,7 +302,8 @@ def config_entries_progress(
[ [
flw flw
for flw in hass.config_entries.flow.async_progress() for flw in hass.config_entries.flow.async_progress()
if flw["context"]["source"] != config_entries.SOURCE_USER if flw["context"]["source"]
not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER)
], ],
) )

View File

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

View File

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

View File

@ -14,8 +14,8 @@
], ],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==1.0.3", "aiodhcpwatcher==1.1.0",
"aiodiscover==2.1.0", "aiodiscover==2.2.2",
"cached-ipaddress==0.8.0" "cached-ipaddress==0.8.0"
] ]
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/econet", "documentation": "https://www.home-assistant.io/integrations/econet",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"], "loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.1.23"] "requirements": ["pyeconet==0.1.26"]
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"] "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,5 +22,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"] "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
} }

View File

@ -18,7 +18,7 @@
"requirements": [ "requirements": [
"aioesphomeapi==29.0.0", "aioesphomeapi==29.0.0",
"esphome-dashboard-api==1.2.3", "esphome-dashboard-api==1.2.3",
"bleak-esphome==2.7.0" "bleak-esphome==2.7.1"
], ],
"zeroconf": ["_esphomelib._tcp.local."] "zeroconf": ["_esphomelib._tcp.local."]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -131,5 +131,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble", "documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["govee-ble==0.42.0"] "requirements": ["govee-ble==0.42.1"]
} }

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
SensorStateClass,
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_LATITUDE, ATTR_LATITUDE,
@ -39,12 +40,31 @@ ATTR_CLIMB = "climb"
ATTR_ELEVATION = "elevation" ATTR_ELEVATION = "elevation"
ATTR_GPS_TIME = "gps_time" ATTR_GPS_TIME = "gps_time"
ATTR_SPEED = "speed" ATTR_SPEED = "speed"
ATTR_TOTAL_SATELLITES = "total_satellites"
ATTR_USED_SATELLITES = "used_satellites"
DEFAULT_NAME = "GPS" DEFAULT_NAME = "GPS"
_MODE_VALUES = {2: "2d_fix", 3: "3d_fix"} _MODE_VALUES = {2: "2d_fix", 3: "3d_fix"}
def count_total_satellites_fn(agps_thread: AGPS3mechanism) -> int | None:
"""Count the number of total satellites."""
satellites = agps_thread.data_stream.satellites
return None if satellites == "n/a" else len(satellites)
def count_used_satellites_fn(agps_thread: AGPS3mechanism) -> int | None:
"""Count the number of used satellites."""
satellites = agps_thread.data_stream.satellites
if satellites == "n/a":
return None
return sum(
1 for sat in satellites if isinstance(sat, dict) and sat.get("used", False)
)
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class GpsdSensorDescription(SensorEntityDescription): class GpsdSensorDescription(SensorEntityDescription):
"""Class describing GPSD sensor entities.""" """Class describing GPSD sensor entities."""
@ -116,6 +136,22 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = (
suggested_display_precision=2, suggested_display_precision=2,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
), ),
GpsdSensorDescription(
key=ATTR_TOTAL_SATELLITES,
translation_key=ATTR_TOTAL_SATELLITES,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=count_total_satellites_fn,
entity_registry_enabled_default=False,
),
GpsdSensorDescription(
key=ATTR_USED_SATELLITES,
translation_key=ATTR_USED_SATELLITES,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
value_fn=count_used_satellites_fn,
entity_registry_enabled_default=False,
),
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reconfiguration of the integration.""" """Handle reconfiguration of the integration."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input: if user_input:
try: try:
device_info = await async_try_connect(user_input[CONF_IP_ADDRESS]) device_info = await async_try_connect(
user_input[CONF_IP_ADDRESS],
token=reconfigure_entry.data.get(CONF_TOKEN),
)
except RecoverableError as ex: except RecoverableError as ex:
LOGGER.error(ex) LOGGER.error(ex)
@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(), self._get_reconfigure_entry(),
data_updates=user_input, data_updates=user_input,
) )
reconfigure_entry = self._get_reconfigure_entry()
return self.async_show_form( return self.async_show_form(
step_id="reconfigure", step_id="reconfigure",
data_schema=vol.Schema( data_schema=vol.Schema(
@ -306,7 +310,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
) )
async def async_try_connect(ip_address: str) -> Device: async def async_try_connect(ip_address: str, token: str | None = None) -> Device:
"""Try to connect. """Try to connect.
Make connection with device to test the connection Make connection with device to test the connection
@ -317,7 +321,7 @@ async def async_try_connect(ip_address: str) -> Device:
# Determine if device is v1 or v2 capable # Determine if device is v1 or v2 capable
if await has_v2_api(ip_address): if await has_v2_api(ip_address):
energy_api = HomeWizardEnergyV2(ip_address) energy_api = HomeWizardEnergyV2(ip_address, token=token)
else: else:
energy_api = HomeWizardEnergyV1(ip_address) energy_api = HomeWizardEnergyV1(ip_address)

View File

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

View File

@ -0,0 +1,39 @@
"""The IOmeter integration."""
from __future__ import annotations
from iometer import IOmeterClient, IOmeterConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import IOmeterConfigEntry, IOMeterCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool:
"""Set up IOmeter from a config entry."""
host = entry.data[CONF_HOST]
session = async_get_clientsession(hass)
client = IOmeterClient(host=host, session=session)
try:
await client.get_current_status()
except IOmeterConnectionError as err:
raise ConfigEntryNotReady from err
coordinator = IOMeterCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()
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:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,91 @@
"""Config flow for the IOmeter integration."""
from typing import Any, Final
from iometer import IOmeterClient, IOmeterConnectionError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
CONFIG_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str})
class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handles the config flow for a IOmeter bridge and core."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._host: str
self._meter_number: str
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self._host = host = discovery_info.host
self._async_abort_entries_match({CONF_HOST: host})
session = async_get_clientsession(self.hass)
client = IOmeterClient(host=host, session=session)
try:
status = await client.get_current_status()
except IOmeterConnectionError:
return self.async_abort(reason="cannot_connect")
self._meter_number = status.meter.number
await self.async_set_unique_id(status.device.id)
self._abort_if_unique_id_configured()
self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"}
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
return await self._async_create_entry()
self._set_confirm_only()
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"meter_number": self._meter_number},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial configuration."""
errors: dict[str, str] = {}
if user_input is not None:
self._host = user_input[CONF_HOST]
session = async_get_clientsession(self.hass)
client = IOmeterClient(host=self._host, session=session)
try:
status = await client.get_current_status()
except IOmeterConnectionError:
errors["base"] = "cannot_connect"
else:
self._meter_number = status.meter.number
await self.async_set_unique_id(status.device.id)
self._abort_if_unique_id_configured()
return await self._async_create_entry()
return self.async_show_form(
step_id="user",
data_schema=CONFIG_SCHEMA,
errors=errors,
)
async def _async_create_entry(self) -> ConfigFlowResult:
"""Create entry."""
return self.async_create_entry(
title=f"IOmeter {self._meter_number}",
data={CONF_HOST: self._host},
)

View File

@ -0,0 +1,5 @@
"""Constants for the IOmeter integration."""
from typing import Final
DOMAIN: Final = "iometer"

View File

@ -0,0 +1,55 @@
"""DataUpdateCoordinator for IOmeter."""
from dataclasses import dataclass
from datetime import timedelta
import logging
from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator]
@dataclass
class IOmeterData:
"""Class for data update."""
reading: Reading
status: Status
class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]):
"""Class to manage fetching IOmeter data."""
config_entry: IOmeterConfigEntry
client: IOmeterClient
def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
self.client = client
self.identifier = self.config_entry.entry_id
async def _async_update_data(self) -> IOmeterData:
"""Update data async."""
try:
reading = await self.client.get_current_reading()
status = await self.client.get_current_status()
except IOmeterConnectionError as error:
raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error
return IOmeterData(reading=reading, status=status)

View File

@ -0,0 +1,24 @@
"""Base class for IOmeter entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import IOMeterCoordinator
class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]):
"""Defines a base IOmeter entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: IOMeterCoordinator) -> None:
"""Initialize IOmeter entity."""
super().__init__(coordinator)
status = coordinator.data.status
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, status.device.id)},
manufacturer="IOmeter GmbH",
model="IOmeter",
sw_version=f"{status.device.core.version}/{status.device.bridge.version}",
)

View File

@ -0,0 +1,38 @@
{
"entity": {
"sensor": {
"attachment_status": {
"default": "mdi:eye",
"state": {
"attached": "mdi:check-bold",
"detached": "mdi:close",
"unknown": "mdi:help"
}
},
"connection_status": {
"default": "mdi:eye",
"state": {
"connected": "mdi:check-bold",
"disconnected": "mdi:close",
"unknown": "mdi:help"
}
},
"pin_status": {
"default": "mdi:eye",
"state": {
"entered": "mdi:lock-open",
"pending": "mdi:lock-clock",
"missing": "mdi:lock",
"unknown": "mdi:help"
}
},
"power_status": {
"default": "mdi:eye",
"state": {
"battery": "mdi:battery",
"wired": "mdi:power-plug"
}
}
}
}
}

View File

@ -0,0 +1,12 @@
{
"domain": "iometer",
"name": "IOmeter",
"codeowners": ["@MaestroOnICe"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/iometer",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["iometer==0.1.0"],
"zeroconf": ["_iometer._tcp.local."]
}

View File

@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: This integration does not register any events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration has not option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: exempt
comment: This integration polls data using a coordinator, there is no need for parallel updates.
reauthentication-flow:
status: exempt
comment: This integration requires no authentication.
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@ -0,0 +1,146 @@
"""IOmeter sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
STATE_UNKNOWN,
EntityCategory,
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import IOMeterCoordinator, IOmeterData
from .entity import IOmeterEntity
@dataclass(frozen=True, kw_only=True)
class IOmeterEntityDescription(SensorEntityDescription):
"""Describes IOmeter sensor entity."""
value_fn: Callable[[IOmeterData], str | int | float]
SENSOR_TYPES: list[IOmeterEntityDescription] = [
IOmeterEntityDescription(
key="meter_number",
translation_key="meter_number",
icon="mdi:meter-electric",
value_fn=lambda data: data.status.meter.number,
),
IOmeterEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.status.device.bridge.rssi,
),
IOmeterEntityDescription(
key="core_bridge_rssi",
translation_key="core_bridge_rssi",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.status.device.core.rssi,
),
IOmeterEntityDescription(
key="power_status",
translation_key="power_status",
device_class=SensorDeviceClass.ENUM,
options=["battery", "wired", "unknown"],
value_fn=lambda data: data.status.device.core.power_status or STATE_UNKNOWN,
),
IOmeterEntityDescription(
key="battery_level",
translation_key="battery_level",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.status.device.core.battery_level,
),
IOmeterEntityDescription(
key="pin_status",
translation_key="pin_status",
device_class=SensorDeviceClass.ENUM,
options=["entered", "pending", "missing", "unknown"],
value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN,
),
IOmeterEntityDescription(
key="total_consumption",
translation_key="total_consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.reading.get_total_consumption(),
),
IOmeterEntityDescription(
key="total_production",
translation_key="total_production",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.reading.get_total_production(),
),
IOmeterEntityDescription(
key="power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.reading.get_current_power(),
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Sensors."""
coordinator: IOMeterCoordinator = config_entry.runtime_data
async_add_entities(
IOmeterSensor(
coordinator=coordinator,
description=description,
)
for description in SENSOR_TYPES
)
class IOmeterSensor(IOmeterEntity, SensorEntity):
"""Defines a IOmeter sensor."""
entity_description: IOmeterEntityDescription
def __init__(
self,
coordinator: IOMeterCoordinator,
description: IOmeterEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.identifier}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -0,0 +1,65 @@
{
"config": {
"step": {
"user": {
"description": "Setup your IOmeter device for local data",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of the IOmeter device to connect to."
}
},
"zeroconf_confirm": {
"title": "Discovered IOmeter",
"description": "Do you want to set up IOmeter on the meter with meter number: {meter_number}?"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "Unexpected error"
}
},
"entity": {
"sensor": {
"battery_level": {
"name": "Battery level"
},
"meter_number": {
"name": "Meter number"
},
"pin_status": {
"name": "PIN status",
"state": {
"entered": "Entered",
"pending": "Pending",
"missing": "Missing",
"unknown": "Unknown"
}
},
"power_status": {
"name": "Power supply",
"state": {
"battery": "Battery",
"wired": "Wired"
}
},
"total_consumption": {
"name": "Total consumption"
},
"total_production": {
"name": "Total production"
},
"core_bridge_rssi": {
"name": "Signal strength Core/Bridge"
},
"wifi_rssi": {
"name": "Signal strength Wi-Fi"
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"] "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"]
} }

View File

@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble", "documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"] "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"]
} }

View File

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

View File

@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [
platform=Platform.SELECT, platform=Platform.SELECT,
entity_description=MatterSelectEntityDescription( entity_description=MatterSelectEntityDescription(
key="MatterDeviceEnergyManagementMode", key="MatterDeviceEnergyManagementMode",
translation_key="mode", translation_key="device_energy_management_mode",
), ),
entity_class=MatterModeSelectEntity, entity_class=MatterModeSelectEntity,
required_attributes=( required_attributes=(

View File

@ -183,6 +183,9 @@
"mode": { "mode": {
"name": "Mode" "name": "Mode"
}, },
"device_energy_management_mode": {
"name": "Energy management mode"
},
"sensitivity_level": { "sensitivity_level": {
"name": "Sensitivity", "name": "Sensitivity",
"state": { "state": {

View File

@ -35,13 +35,13 @@ class StatelessAssistAPI(llm.AssistAPI):
"""Return the prompt for the exposed entities.""" """Return the prompt for the exposed entities."""
prompt = [] prompt = []
if exposed_entities: if exposed_entities and exposed_entities["entities"]:
prompt.append( prompt.append(
"An overview of the areas and the devices in this smart home:" "An overview of the areas and the devices in this smart home:"
) )
entities = [ entities = [
{k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS} {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS}
for entity_info in exposed_entities.values() for entity_info in exposed_entities["entities"].values()
] ]
prompt.append(yaml_util.dump(list(entities))) prompt.append(yaml_util.dump(list(entities)))

View File

@ -1,6 +1,9 @@
"""Support for MotionMount sensors.""" """Support for MotionMount sensors."""
from typing import Final
import motionmount import motionmount
from motionmount import MotionMountSystemError
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -9,6 +12,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MotionMountConfigEntry from . import MotionMountConfigEntry
from .entity import MotionMountEntity from .entity import MotionMountEntity
ERROR_MESSAGES: Final = {
MotionMountSystemError.MotorError: "motor",
MotionMountSystemError.ObstructionDetected: "obstruction",
MotionMountSystemError.TVWidthConstraintError: "tv_width_constraint",
MotionMountSystemError.HDMICECError: "hdmi_cec",
MotionMountSystemError.InternalError: "internal",
}
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -25,7 +36,14 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
"""The error status sensor of a MotionMount.""" """The error status sensor of a MotionMount."""
_attr_device_class = SensorDeviceClass.ENUM _attr_device_class = SensorDeviceClass.ENUM
_attr_options = ["none", "motor", "internal"] _attr_options = [
"none",
"motor",
"hdmi_cec",
"obstruction",
"tv_width_constraint",
"internal",
]
_attr_translation_key = "motionmount_error_status" _attr_translation_key = "motionmount_error_status"
def __init__( def __init__(
@ -38,13 +56,10 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
@property @property
def native_value(self) -> str: def native_value(self) -> str:
"""Return error status.""" """Return error status."""
errors = self.mm.error_status or 0 status = self.mm.system_status
if errors & (1 << 31): for error, message in ERROR_MESSAGES.items():
# Only when but 31 is set are there any errors active at this moment if error in status:
if errors & (1 << 10): return message
return "motor"
return "internal"
return "none" return "none"

View File

@ -72,6 +72,9 @@
"state": { "state": {
"none": "None", "none": "None",
"motor": "Motor", "motor": "Motor",
"hdmi_cec": "HDMI CEC",
"obstruction": "Obstruction",
"tv_width_constraint": "TV width constraint",
"internal": "Internal" "internal": "Internal"
} }
} }

View File

@ -51,10 +51,10 @@ class AsyncMQTTClient(MQTTClient):
since the client is running in an async event loop since the client is running in an async event loop
and will never run in multiple threads. and will never run in multiple threads.
""" """
self._in_callback_mutex = NullLock() self._in_callback_mutex = NullLock() # type: ignore[assignment]
self._callback_mutex = NullLock() self._callback_mutex = NullLock() # type: ignore[assignment]
self._msgtime_mutex = NullLock() self._msgtime_mutex = NullLock() # type: ignore[assignment]
self._out_message_mutex = NullLock() self._out_message_mutex = NullLock() # type: ignore[assignment]
self._in_message_mutex = NullLock() self._in_message_mutex = NullLock() # type: ignore[assignment]
self._reconnect_delay_mutex = NullLock() self._reconnect_delay_mutex = NullLock() # type: ignore[assignment]
self._mid_generate_mutex = NullLock() self._mid_generate_mutex = NullLock() # type: ignore[assignment]

View File

@ -15,7 +15,6 @@ import socket
import ssl import ssl
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
import uuid
import certifi import certifi
@ -117,7 +116,7 @@ MAX_UNSUBSCRIBES_PER_CALL = 500
MAX_PACKETS_TO_READ = 500 MAX_PACKETS_TO_READ = 500
type SocketType = socket.socket | ssl.SSLSocket | mqtt.WebsocketWrapper | Any type SocketType = socket.socket | ssl.SSLSocket | mqtt._WebsocketWrapper | Any # noqa: SLF001
type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None type SubscribePayloadType = str | bytes | bytearray # Only bytes if encoding is None
@ -309,12 +308,13 @@ class MqttClientSetup:
if (client_id := config.get(CONF_CLIENT_ID)) is None: if (client_id := config.get(CONF_CLIENT_ID)) is None:
# PAHO MQTT relies on the MQTT server to generate random client IDs. # PAHO MQTT relies on the MQTT server to generate random client IDs.
# However, that feature is not mandatory so we generate our own. # However, that feature is not mandatory so we generate our own.
client_id = mqtt.base62(uuid.uuid4().int, padding=22) client_id = None
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
self._client = AsyncMQTTClient( self._client = AsyncMQTTClient(
mqtt.CallbackAPIVersion.VERSION1,
client_id, client_id,
protocol=proto, protocol=proto,
transport=transport, transport=transport, # type: ignore[arg-type]
reconnect_on_failure=False, reconnect_on_failure=False,
) )
self._client.setup() self._client.setup()
@ -533,7 +533,7 @@ class MQTT:
try: try:
# Some operating systems do not allow us to set the preferred # Some operating systems do not allow us to set the preferred
# buffer size. In that case we try some other size options. # buffer size. In that case we try some other size options.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size) # type: ignore[union-attr]
except OSError as err: except OSError as err:
if new_buffer_size <= MIN_BUFFER_SIZE: if new_buffer_size <= MIN_BUFFER_SIZE:
_LOGGER.warning( _LOGGER.warning(
@ -1216,7 +1216,9 @@ class MQTT:
if not future.done(): if not future.done():
future.set_exception(asyncio.TimeoutError) future.set_exception(asyncio.TimeoutError)
async def _async_wait_for_mid_or_raise(self, mid: int, result_code: int) -> None: async def _async_wait_for_mid_or_raise(
self, mid: int | None, result_code: int
) -> None:
"""Wait for ACK from broker or raise on error.""" """Wait for ACK from broker or raise on error."""
if result_code != 0: if result_code != 0:
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
@ -1232,6 +1234,8 @@ class MQTT:
# Create the mid event if not created, either _mqtt_handle_mid or # Create the mid event if not created, either _mqtt_handle_mid or
# _async_wait_for_mid_or_raise may be executed first. # _async_wait_for_mid_or_raise may be executed first.
if TYPE_CHECKING:
assert mid is not None
future = self._async_get_mid_future(mid) future = self._async_get_mid_future(mid)
loop = self.hass.loop loop = self.hass.loop
timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future)
@ -1269,7 +1273,7 @@ def _matcher_for_topic(subscription: str) -> Callable[[str], bool]:
# pylint: disable-next=import-outside-toplevel # pylint: disable-next=import-outside-toplevel
from paho.mqtt.matcher import MQTTMatcher from paho.mqtt.matcher import MQTTMatcher
matcher = MQTTMatcher() matcher = MQTTMatcher() # type: ignore[no-untyped-call]
matcher[subscription] = True matcher[subscription] = True
return lambda topic: next(matcher.iter_match(topic), False) return lambda topic: next(matcher.iter_match(topic), False) # type: ignore[no-untyped-call]

View File

@ -8,6 +8,6 @@
"documentation": "https://www.home-assistant.io/integrations/mqtt", "documentation": "https://www.home-assistant.io/integrations/mqtt",
"iot_class": "local_push", "iot_class": "local_push",
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["paho-mqtt==1.6.1"], "requirements": ["paho-mqtt==2.1.0"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia", "documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["nexia"], "loggers": ["nexia"],
"requirements": ["nexia==2.0.8"] "requirements": ["nexia==2.0.9"]
} }

View File

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aionut"], "loggers": ["aionut"],
"requirements": ["aionut==4.3.3"], "requirements": ["aionut==4.3.4"],
"zeroconf": ["_nut._tcp.local."] "zeroconf": ["_nut._tcp.local."]
} }

View File

@ -18,6 +18,8 @@ _LOGGER = logging.getLogger(__name__)
class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]):
"""Class to manage fetching update data from single endpoint.""" """Class to manage fetching update data from single endpoint."""
config_entry: ConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
@ -28,11 +30,11 @@ class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any
) -> None: ) -> None:
"""Initialize the global Omnilogic data updater.""" """Initialize the global Omnilogic data updater."""
self.api = api self.api = api
self.config_entry = config_entry
super().__init__( super().__init__(
hass=hass, hass=hass,
logger=_LOGGER, logger=_LOGGER,
config_entry=config_entry,
name=name, name=name,
update_interval=timedelta(seconds=polling_interval), update_interval=timedelta(seconds=polling_interval),
) )

View File

@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/oncue", "documentation": "https://www.home-assistant.io/integrations/oncue",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aiooncue"], "loggers": ["aiooncue"],
"requirements": ["aiooncue==0.3.7"] "requirements": ["aiooncue==0.3.9"]
} }

View File

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import cast
from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import ( from onedrive_personal_sdk.exceptions import (
@ -13,6 +15,7 @@ from onedrive_personal_sdk.exceptions import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -22,7 +25,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
) )
from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .api import OneDriveConfigEntryAccessTokenProvider
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
@ -31,7 +33,7 @@ class OneDriveRuntimeData:
"""Runtime data for the OneDrive integration.""" """Runtime data for the OneDrive integration."""
client: OneDriveClient client: OneDriveClient
token_provider: OneDriveConfigEntryAccessTokenProvider token_function: Callable[[], Awaitable[str]]
backup_folder_id: str backup_folder_id: str
@ -46,9 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
session = OAuth2Session(hass, entry, implementation) session = OAuth2Session(hass, entry, implementation)
token_provider = OneDriveConfigEntryAccessTokenProvider(session) async def get_access_token() -> str:
await session.async_ensure_token_valid()
return cast(str, session.token[CONF_ACCESS_TOKEN])
client = OneDriveClient(token_provider, async_get_clientsession(hass)) client = OneDriveClient(get_access_token, async_get_clientsession(hass))
# get approot, will be created automatically if it does not exist # get approot, will be created automatically if it does not exist
try: try:
@ -81,7 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
entry.runtime_data = OneDriveRuntimeData( entry.runtime_data = OneDriveRuntimeData(
client=client, client=client,
token_provider=token_provider, token_function=get_access_token,
backup_folder_id=backup_folder.id, backup_folder_id=backup_folder.id,
) )

View File

@ -1,34 +0,0 @@
"""API for OneDrive bound to Home Assistant OAuth."""
from typing import cast
from onedrive_personal_sdk import TokenProvider
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers import config_entry_oauth2_flow
class OneDriveConfigFlowAccessTokenProvider(TokenProvider):
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
def __init__(self, token: str) -> None:
"""Initialize OneDrive auth."""
super().__init__()
self._token = token
def async_get_access_token(self) -> str:
"""Return a valid access token."""
return self._token
class OneDriveConfigEntryAccessTokenProvider(TokenProvider):
"""Provide OneDrive authentication tied to an OAuth2 based config entry."""
def __init__(self, oauth_session: config_entry_oauth2_flow.OAuth2Session) -> None:
"""Initialize OneDrive auth."""
super().__init__()
self._oauth_session = oauth_session
def async_get_access_token(self) -> str:
"""Return a valid access token."""
return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN])

View File

@ -109,7 +109,7 @@ class OneDriveBackupAgent(BackupAgent):
self._hass = hass self._hass = hass
self._entry = entry self._entry = entry
self._client = entry.runtime_data.client self._client = entry.runtime_data.client
self._token_provider = entry.runtime_data.token_provider self._token_function = entry.runtime_data.token_function
self._folder_id = entry.runtime_data.backup_folder_id self._folder_id = entry.runtime_data.backup_folder_id
self.name = entry.title self.name = entry.title
assert entry.unique_id assert entry.unique_id
@ -145,7 +145,7 @@ class OneDriveBackupAgent(BackupAgent):
) )
try: try:
item = await LargeFileUploadClient.upload( item = await LargeFileUploadClient.upload(
self._token_provider, file, session=async_get_clientsession(self._hass) self._token_function, file, session=async_get_clientsession(self._hass)
) )
except HashMismatchError as err: except HashMismatchError as err:
raise BackupAgentError( raise BackupAgentError(

View File

@ -12,7 +12,6 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .api import OneDriveConfigFlowAccessTokenProvider
from .const import DOMAIN, OAUTH_SCOPES from .const import DOMAIN, OAUTH_SCOPES
@ -36,12 +35,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
data: dict[str, Any], data: dict[str, Any],
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial step."""
token_provider = OneDriveConfigFlowAccessTokenProvider(
cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) async def get_access_token() -> str:
) return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
graph_client = OneDriveClient( graph_client = OneDriveClient(
token_provider, async_get_clientsession(self.hass) get_access_token, async_get_clientsession(self.hass)
) )
try: try:

View File

@ -9,5 +9,5 @@
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"], "loggers": ["onedrive_personal_sdk"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["onedrive-personal-sdk==0.0.3"] "requirements": ["onedrive-personal-sdk==0.0.8"]
} }

View File

@ -16,10 +16,10 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"account": "OVO account id (only add if you have multiple accounts)" "account": "OVO account ID (only add if you have multiple accounts)"
}, },
"description": "Set up an OVO Energy instance to access your energy usage.", "description": "Set up an OVO Energy instance to access your energy usage.",
"title": "Add OVO Energy Account" "title": "Add OVO Energy account"
}, },
"reauth_confirm": { "reauth_confirm": {
"data": { "data": {

View File

@ -21,6 +21,8 @@ from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT
class PicnicUpdateCoordinator(DataUpdateCoordinator): class PicnicUpdateCoordinator(DataUpdateCoordinator):
"""The coordinator to fetch data from the Picnic API at a set interval.""" """The coordinator to fetch data from the Picnic API at a set interval."""
config_entry: ConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
@ -29,13 +31,13 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator):
) -> None: ) -> None:
"""Initialize the coordinator with the given Picnic API client.""" """Initialize the coordinator with the given Picnic API client."""
self.picnic_api_client = picnic_api_client self.picnic_api_client = picnic_api_client
self.config_entry = config_entry
self._user_address = None self._user_address = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
super().__init__( super().__init__(
hass, hass,
logger, logger,
config_entry=config_entry,
name="Picnic coordinator", name="Picnic coordinator",
update_interval=timedelta(minutes=30), update_interval=timedelta(minutes=30),
) )

View File

@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device", "documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.23.3"] "requirements": ["bluetooth-data-tools==1.23.4"]
} }

View File

@ -5,7 +5,7 @@
"user": { "user": {
"title": "Fill in your information", "title": "Fill in your information",
"data": { "data": {
"ip_address": "Hostname or IP Address", "ip_address": "Hostname or IP address",
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
} }
@ -157,7 +157,7 @@
}, },
"unpause_watering": { "unpause_watering": {
"name": "Unpause all watering", "name": "Unpause all watering",
"description": "Unpauses all paused watering activities.", "description": "Resumes all paused watering activities.",
"fields": { "fields": {
"device_id": { "device_id": {
"name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]",
@ -167,7 +167,7 @@
}, },
"push_flow_meter_data": { "push_flow_meter_data": {
"name": "Push flow meter data", "name": "Push flow meter data",
"description": "Push flow meter data to the RainMachine device.", "description": "Sends flow meter data from Home Assistant to the RainMachine device.",
"fields": { "fields": {
"device_id": { "device_id": {
"name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]",
@ -185,7 +185,7 @@
}, },
"push_weather_data": { "push_weather_data": {
"name": "Push weather data", "name": "Push weather data",
"description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", "description": "Sends weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.",
"fields": { "fields": {
"device_id": { "device_id": {
"name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]",
@ -193,7 +193,7 @@
}, },
"timestamp": { "timestamp": {
"name": "Timestamp", "name": "Timestamp",
"description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used."
}, },
"mintemp": { "mintemp": {
"name": "Min temp", "name": "Min temp",
@ -251,7 +251,7 @@
}, },
"unrestrict_watering": { "unrestrict_watering": {
"name": "Unrestrict all watering", "name": "Unrestrict all watering",
"description": "Unrestrict all watering activities.", "description": "Removes all watering restrictions.",
"fields": { "fields": {
"device_id": { "device_id": {
"name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]",

View File

@ -19,5 +19,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["reolink-aio==0.11.8"] "requirements": ["reolink-aio==0.11.9"]
} }

View File

@ -424,6 +424,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="image_brightness", key="image_brightness",
cmd_key="GetImage", cmd_key="GetImage",
cmd_id=26,
translation_key="image_brightness", translation_key="image_brightness",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -437,6 +438,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="image_contrast", key="image_contrast",
cmd_key="GetImage", cmd_key="GetImage",
cmd_id=26,
translation_key="image_contrast", translation_key="image_contrast",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -450,6 +452,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="image_saturation", key="image_saturation",
cmd_key="GetImage", cmd_key="GetImage",
cmd_id=26,
translation_key="image_saturation", translation_key="image_saturation",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -463,6 +466,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="image_sharpness", key="image_sharpness",
cmd_key="GetImage", cmd_key="GetImage",
cmd_id=26,
translation_key="image_sharpness", translation_key="image_sharpness",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -476,6 +480,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription( ReolinkNumberEntityDescription(
key="image_hue", key="image_hue",
cmd_key="GetImage", cmd_key="GetImage",
cmd_id=26,
translation_key="image_hue", translation_key="image_hue",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,

View File

@ -80,6 +80,7 @@ SELECT_ENTITIES = (
ReolinkSelectEntityDescription( ReolinkSelectEntityDescription(
key="day_night_mode", key="day_night_mode",
cmd_key="GetIsp", cmd_key="GetIsp",
cmd_id=26,
translation_key="day_night_mode", translation_key="day_night_mode",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
get_options=[mode.name for mode in DayNightEnum], get_options=[mode.name for mode in DayNightEnum],

View File

@ -24,7 +24,7 @@
"documentation": "https://www.home-assistant.io/integrations/roomba", "documentation": "https://www.home-assistant.io/integrations/roomba",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["paho_mqtt", "roombapy"], "loggers": ["paho_mqtt", "roombapy"],
"requirements": ["roombapy==1.8.1"], "requirements": ["roombapy==1.9.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_amzn-alexa._tcp.local.", "type": "_amzn-alexa._tcp.local.",

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