mirror of
https://github.com/home-assistant/core.git
synced 2025-07-26 22:57:17 +00:00
Merge branch 'dev' into dev
This commit is contained in:
commit
36593c3a38
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@ -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"
|
||||||
|
|
||||||
|
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@ -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
2
CODEOWNERS
generated
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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(
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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]
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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"
|
||||||
|
@ -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:
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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%]"
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
|
@ -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:
|
||||||
|
213
homeassistant/components/fireservicerota/coordinator.py
Normal file
213
homeassistant/components/fireservicerota/coordinator.py
Normal 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)
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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())
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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": {
|
||||||
|
39
homeassistant/components/iometer/__init__.py
Normal file
39
homeassistant/components/iometer/__init__.py
Normal 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)
|
91
homeassistant/components/iometer/config_flow.py
Normal file
91
homeassistant/components/iometer/config_flow.py
Normal 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},
|
||||||
|
)
|
5
homeassistant/components/iometer/const.py
Normal file
5
homeassistant/components/iometer/const.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Constants for the IOmeter integration."""
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "iometer"
|
55
homeassistant/components/iometer/coordinator.py
Normal file
55
homeassistant/components/iometer/coordinator.py
Normal 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)
|
24
homeassistant/components/iometer/entity.py
Normal file
24
homeassistant/components/iometer/entity.py
Normal 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}",
|
||||||
|
)
|
38
homeassistant/components/iometer/icons.json
Normal file
38
homeassistant/components/iometer/icons.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
homeassistant/components/iometer/manifest.json
Normal file
12
homeassistant/components/iometer/manifest.json
Normal 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."]
|
||||||
|
}
|
74
homeassistant/components/iometer/quality_scale.yaml
Normal file
74
homeassistant/components/iometer/quality_scale.yaml
Normal 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
|
146
homeassistant/components/iometer/sensor.py
Normal file
146
homeassistant/components/iometer/sensor.py
Normal 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)
|
65
homeassistant/components/iometer/strings.json
Normal file
65
homeassistant/components/iometer/strings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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": {
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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=(
|
||||||
|
@ -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": {
|
||||||
|
@ -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)))
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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]
|
||||||
|
@ -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]
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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])
|
|
@ -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(
|
||||||
|
@ -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:
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
||||||
|
@ -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),
|
||||||
)
|
)
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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%]",
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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],
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user