mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 12:47:08 +00:00
Merge branch 'dev' into aranet-threshold-level
This commit is contained in:
commit
d567552c19
2
Dockerfile
generated
2
Dockerfile
generated
@ -13,7 +13,7 @@ ENV \
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.5.27
|
||||
RUN pip3 install uv==0.6.0
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
|
||||
"""Unload AdGuard Home config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# This is the last loaded instance of AdGuard, deregister any services
|
||||
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
|
||||
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
|
||||
|
@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airgradient",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["airgradient==0.9.1"],
|
||||
"requirements": ["airgradient==0.9.2"],
|
||||
"zeroconf": ["_airgradient._tcp.local."]
|
||||
}
|
||||
|
@ -17,13 +17,13 @@ class BroadlinkEntity(Entity):
|
||||
self._device = device
|
||||
self._coordinator = device.update_manager.coordinator
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when the entity is added to hass."""
|
||||
self.async_on_remove(self._coordinator.async_add_listener(self._recv_data))
|
||||
if self._coordinator.data:
|
||||
self._update_state(self._coordinator.data)
|
||||
|
||||
async def async_update(self):
|
||||
async def async_update(self) -> None:
|
||||
"""Update the state of the entity."""
|
||||
await self._coordinator.async_request_refresh()
|
||||
|
||||
@ -49,7 +49,7 @@ class BroadlinkEntity(Entity):
|
||||
"""
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
return self._device.available
|
||||
|
||||
|
@ -11,7 +11,11 @@ from typing import Any
|
||||
from aiohttp import ClientError
|
||||
from hass_nabucasa import Cloud, CloudError
|
||||
from hass_nabucasa.api import CloudApiNonRetryableError
|
||||
from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
|
||||
from hass_nabucasa.cloud_api import (
|
||||
FilesHandlerListEntry,
|
||||
async_files_delete_file,
|
||||
async_files_list,
|
||||
)
|
||||
from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
|
||||
|
||||
from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
|
||||
@ -76,11 +80,6 @@ class CloudBackupAgent(BackupAgent):
|
||||
self._cloud = cloud
|
||||
self._hass = hass
|
||||
|
||||
@callback
|
||||
def _get_backup_filename(self) -> str:
|
||||
"""Return the backup filename."""
|
||||
return f"{self._cloud.client.prefs.instance_id}.tar"
|
||||
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
@ -91,13 +90,13 @@ class CloudBackupAgent(BackupAgent):
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: An async iterator that yields bytes.
|
||||
"""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
raise BackupAgentError("Backup not found")
|
||||
|
||||
try:
|
||||
content = await self._cloud.files.download(
|
||||
storage_type=StorageType.BACKUP,
|
||||
filename=self._get_backup_filename(),
|
||||
filename=backup["Key"],
|
||||
)
|
||||
except CloudError as err:
|
||||
raise BackupAgentError(f"Failed to download backup: {err}") from err
|
||||
@ -124,7 +123,7 @@ class CloudBackupAgent(BackupAgent):
|
||||
base64md5hash = await calculate_b64md5(open_stream, size)
|
||||
except FilesError as err:
|
||||
raise BackupAgentError(err) from err
|
||||
filename = self._get_backup_filename()
|
||||
filename = f"{self._cloud.client.prefs.instance_id}.tar"
|
||||
metadata = backup.as_dict()
|
||||
|
||||
tries = 1
|
||||
@ -172,29 +171,34 @@ class CloudBackupAgent(BackupAgent):
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
"""
|
||||
if not await self.async_get_backup(backup_id):
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
return
|
||||
|
||||
try:
|
||||
await async_files_delete_file(
|
||||
self._cloud,
|
||||
storage_type=StorageType.BACKUP,
|
||||
filename=self._get_backup_filename(),
|
||||
filename=backup["Key"],
|
||||
)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to delete backup") from err
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups = await self._async_list_backups()
|
||||
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
|
||||
|
||||
async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
|
||||
"""List backups."""
|
||||
try:
|
||||
backups = await async_files_list(
|
||||
self._cloud, storage_type=StorageType.BACKUP
|
||||
)
|
||||
_LOGGER.debug("Cloud backups: %s", backups)
|
||||
except (ClientError, CloudError) as err:
|
||||
raise BackupAgentError("Failed to list backups") from err
|
||||
|
||||
return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
|
||||
_LOGGER.debug("Cloud backups: %s", backups)
|
||||
return backups
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
@ -202,10 +206,19 @@ class CloudBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
backups = await self.async_list_backups()
|
||||
if not (backup := await self._async_get_backup(backup_id)):
|
||||
return None
|
||||
return AgentBackup.from_dict(backup["Metadata"])
|
||||
|
||||
async def _async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
) -> FilesHandlerListEntry | None:
|
||||
"""Return a backup."""
|
||||
backups = await self._async_list_backups()
|
||||
|
||||
for backup in backups:
|
||||
if backup.backup_id == backup_id:
|
||||
if backup["Metadata"]["backup_id"] == backup_id:
|
||||
return backup
|
||||
|
||||
return None
|
||||
|
@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
from aiohttp import ClientError
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.hub import EheimDigitalHub
|
||||
from eheimdigital.types import EheimDeviceType
|
||||
from eheimdigital.types import EheimDeviceType, EheimDigitalClientError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@ -43,12 +45,14 @@ class EheimDigitalUpdateCoordinator(
|
||||
name=DOMAIN,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
self.main_device_added_event = asyncio.Event()
|
||||
self.hub = EheimDigitalHub(
|
||||
host=self.config_entry.data[CONF_HOST],
|
||||
session=async_get_clientsession(hass),
|
||||
loop=hass.loop,
|
||||
receive_callback=self._async_receive_callback,
|
||||
device_found_callback=self._async_device_found,
|
||||
main_device_added_event=self.main_device_added_event,
|
||||
)
|
||||
self.known_devices: set[str] = set()
|
||||
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
|
||||
@ -76,8 +80,17 @@ class EheimDigitalUpdateCoordinator(
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
await self.hub.connect()
|
||||
await self.hub.update()
|
||||
try:
|
||||
await self.hub.connect()
|
||||
async with asyncio.timeout(2):
|
||||
# This event gets triggered when the first message is received from
|
||||
# the device, it contains the data necessary to create the main device.
|
||||
# This removes the race condition where the main device is accessed
|
||||
# before the response from the device is parsed.
|
||||
await self.main_device_added_event.wait()
|
||||
await self.hub.update()
|
||||
except (TimeoutError, EheimDigitalClientError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
async def _async_update_data(self) -> dict[str, EheimDigitalDevice]:
|
||||
try:
|
||||
|
@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle device found via zeroconf."""
|
||||
host = discovery_info.host
|
||||
host = (
|
||||
f"[{discovery_info.ip_address}]"
|
||||
if discovery_info.ip_address.version == 6
|
||||
else str(discovery_info.ip_address)
|
||||
)
|
||||
https_port = (
|
||||
int(discovery_info.port)
|
||||
if discovery_info.port is not None
|
||||
|
@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["sense_energy"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["sense-energy==0.13.4"]
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ class EnOceanEntity(Entity):
|
||||
"""Initialize the device."""
|
||||
self.dev_id = dev_id
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
|
@ -16,7 +16,7 @@
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.0.2",
|
||||
"aioesphomeapi==29.1.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.7.1"
|
||||
],
|
||||
|
@ -47,6 +47,10 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class FlexitBinarySensor(FlexitEntity, BinarySensorEntity):
|
||||
"""Representation of a Flexit binary Sensor."""
|
||||
|
||||
|
@ -25,6 +25,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
MAX_TEMP,
|
||||
MIN_TEMP,
|
||||
PRESET_TO_VENTILATION_MODE_MAP,
|
||||
@ -43,6 +44,9 @@ async def async_setup_entry(
|
||||
async_add_entities([FlexitClimateEntity(config_entry.runtime_data)])
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
"""Flexit air handling unit."""
|
||||
|
||||
@ -130,7 +134,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
try:
|
||||
await self.device.set_ventilation_mode(ventilation_mode)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_preset_mode",
|
||||
translation_placeholders={
|
||||
"preset": str(ventilation_mode),
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@ -150,6 +160,12 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
else:
|
||||
await self.device.set_ventilation_mode(VENTILATION_MODE_HOME)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_hvac_mode",
|
||||
translation_placeholders={
|
||||
"mode": str(hvac_mode),
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
@ -49,7 +49,11 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]):
|
||||
await self.device.update()
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_ready",
|
||||
translation_placeholders={
|
||||
"ip": str(self.config_entry.data[CONF_IP_ADDRESS]),
|
||||
},
|
||||
) from exc
|
||||
|
||||
return self.device
|
||||
|
@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["flexit_bacnet==2.2.3"]
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlexitConfigEntry, FlexitCoordinator
|
||||
from .entity import FlexitEntity
|
||||
|
||||
@ -205,6 +206,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class FlexitNumber(FlexitEntity, NumberEntity):
|
||||
"""Representation of a Flexit Number."""
|
||||
|
||||
@ -246,6 +250,12 @@ class FlexitNumber(FlexitEntity, NumberEntity):
|
||||
try:
|
||||
await set_native_value_fn(int(value))
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_value_error",
|
||||
translation_placeholders={
|
||||
"value": str(value),
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
91
homeassistant/components/flexit_bacnet/quality_scale.yaml
Normal file
91
homeassistant/components/flexit_bacnet/quality_scale.yaml
Normal file
@ -0,0 +1,91 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not define custom 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 use any actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities don't subscribe to events explicitly
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Done implicitly with `await coordinator.async_config_entry_first_refresh()`.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration does not use options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Done implicitly with coordinator.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Done implicitly with coordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
Integration doesn't require any form of authentication.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
entity-translations: done
|
||||
entity-device-class: done
|
||||
devices: done
|
||||
entity-category: todo
|
||||
entity-disabled-by-default: todo
|
||||
discovery: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
diagnostics: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
discovery-update-info: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is not applicable for this integration.
|
||||
docs-use-cases: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-data-update: done
|
||||
docs-known-limitations: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-examples: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: done
|
@ -161,6 +161,10 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class FlexitSensor(FlexitEntity, SensorEntity):
|
||||
"""Representation of a Flexit (bacnet) Sensor."""
|
||||
|
||||
|
@ -5,6 +5,10 @@
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]",
|
||||
"device_id": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"ip_address": "The IP address of the Flexit Nordic device",
|
||||
"device_id": "The device ID of the Flexit Nordic device"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -115,5 +119,22 @@
|
||||
"name": "Cooker hood mode"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"set_value_error": {
|
||||
"message": "Failed setting the value {value}."
|
||||
},
|
||||
"switch_turn": {
|
||||
"message": "Failed to turn the switch {state}."
|
||||
},
|
||||
"set_preset_mode": {
|
||||
"message": "Failed to set preset mode {preset}."
|
||||
},
|
||||
"set_hvac_mode": {
|
||||
"message": "Failed to set HVAC mode {mode}."
|
||||
},
|
||||
"not_ready": {
|
||||
"message": "Timeout while connecting to {ip}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlexitConfigEntry, FlexitCoordinator
|
||||
from .entity import FlexitEntity
|
||||
|
||||
@ -68,6 +69,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
class FlexitSwitch(FlexitEntity, SwitchEntity):
|
||||
"""Representation of a Flexit Switch."""
|
||||
|
||||
@ -94,19 +98,31 @@ class FlexitSwitch(FlexitEntity, SwitchEntity):
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn electric heater on."""
|
||||
"""Turn switch on."""
|
||||
try:
|
||||
await self.entity_description.turn_on_fn(self.coordinator.data)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn",
|
||||
translation_placeholders={
|
||||
"state": "on",
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn electric heater off."""
|
||||
"""Turn switch off."""
|
||||
try:
|
||||
await self.entity_description.turn_off_fn(self.coordinator.data)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError from exc
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_turn",
|
||||
translation_placeholders={
|
||||
"state": "off",
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
await self.coordinator.async_refresh()
|
||||
|
@ -45,10 +45,10 @@ class FloEntity(Entity):
|
||||
"""Return True if device is available."""
|
||||
return self._device.available
|
||||
|
||||
async def async_update(self):
|
||||
async def async_update(self) -> None:
|
||||
"""Update Flo entity."""
|
||||
await self._device.async_request_refresh()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state))
|
||||
|
@ -35,7 +35,7 @@
|
||||
"services": {
|
||||
"ptz": {
|
||||
"name": "PTZ",
|
||||
"description": "Pan/Tilt action for Foscam camera.",
|
||||
"description": "Moves a Foscam camera to a specified direction.",
|
||||
"fields": {
|
||||
"movement": {
|
||||
"name": "Movement",
|
||||
@ -49,7 +49,7 @@
|
||||
},
|
||||
"ptz_preset": {
|
||||
"name": "PTZ preset",
|
||||
"description": "PTZ Preset action for Foscam camera.",
|
||||
"description": "Moves a Foscam camera to a predefined position.",
|
||||
"fields": {
|
||||
"preset_name": {
|
||||
"name": "Preset name",
|
||||
|
@ -196,6 +196,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.hass = hass
|
||||
self.host = host
|
||||
self.mesh_role = MeshRoles.NONE
|
||||
self.mesh_wifi_uplink = False
|
||||
self.device_conn_type: str | None = None
|
||||
self.device_is_router: bool = False
|
||||
self.password = password
|
||||
@ -610,6 +611,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
ssid=interf.get("ssid", ""),
|
||||
type=interf["type"],
|
||||
)
|
||||
|
||||
if interf["type"].lower() == "wlan" and interf[
|
||||
"name"
|
||||
].lower().startswith("uplink"):
|
||||
self.mesh_wifi_uplink = True
|
||||
|
||||
if dr.format_mac(int_mac) == self.mac:
|
||||
self.mesh_role = MeshRoles(node["mesh_role"])
|
||||
|
||||
|
@ -207,8 +207,9 @@ async def async_all_entities_list(
|
||||
local_ip: str,
|
||||
) -> list[Entity]:
|
||||
"""Get a list of all entities."""
|
||||
|
||||
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
|
||||
if not avm_wrapper.mesh_wifi_uplink:
|
||||
return [*await _async_wifi_entities_list(avm_wrapper, device_friendly_name)]
|
||||
return []
|
||||
|
||||
return [
|
||||
@ -565,6 +566,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
|
||||
|
||||
self._attributes = {}
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
avm_wrapper.mesh_role is not MeshRoles.SLAVE
|
||||
)
|
||||
self._network_num = network_num
|
||||
|
||||
switch_info = SwitchInfo(
|
||||
|
@ -10,7 +10,7 @@ from google.oauth2.credentials import Credentials
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@ -99,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
for service_name in hass.services.async_services_for_domain(DOMAIN):
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
@ -59,12 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
for service_name in hass.services.async_services_for_domain(DOMAIN):
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
|
@ -12,7 +12,7 @@ from gspread.exceptions import APIError
|
||||
from gspread.utils import ValueInputOption
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import (
|
||||
@ -81,12 +81,7 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleSheetsConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
for service_name in hass.services.async_services_for_domain(DOMAIN):
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
|
@ -11,7 +11,7 @@ from aioguardian import Client
|
||||
from aioguardian.errors import GuardianError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_ID,
|
||||
CONF_DEVICE_ID,
|
||||
@ -247,12 +247,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# If this is the last loaded instance of Guardian, deregister any services
|
||||
# defined during integration setup:
|
||||
for service_name in SERVICES:
|
||||
|
@ -69,3 +69,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove config entry from device if no longer present."""
|
||||
return not any(
|
||||
(domain, key)
|
||||
for domain, key in device.identifiers
|
||||
if domain == DOMAIN and int(key) in entry.runtime_data.heos.players
|
||||
)
|
||||
|
@ -16,6 +16,7 @@ from pyheos import (
|
||||
HeosError,
|
||||
HeosNowPlayingMedia,
|
||||
HeosOptions,
|
||||
HeosPlayer,
|
||||
MediaItem,
|
||||
MediaType,
|
||||
PlayerUpdateResult,
|
||||
@ -58,6 +59,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
credentials=credentials,
|
||||
)
|
||||
)
|
||||
self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = []
|
||||
self._update_sources_pending: bool = False
|
||||
self._source_list: list[str] = []
|
||||
self._favorites: dict[int, MediaItem] = {}
|
||||
@ -124,6 +126,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
self.async_update_listeners()
|
||||
return remove_listener
|
||||
|
||||
def async_add_platform_callback(
|
||||
self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None]
|
||||
) -> None:
|
||||
"""Add a callback to add entities for a platform."""
|
||||
self._platform_callbacks.append(add_entities_callback)
|
||||
|
||||
def _async_handle_player_update_result(
|
||||
self, update_result: PlayerUpdateResult
|
||||
) -> None:
|
||||
"""Handle a player update result."""
|
||||
if update_result.added_player_ids and self._platform_callbacks:
|
||||
new_players = [
|
||||
self.heos.players[player_id]
|
||||
for player_id in update_result.added_player_ids
|
||||
]
|
||||
for add_entities_callback in self._platform_callbacks:
|
||||
add_entities_callback(new_players)
|
||||
|
||||
if update_result.updated_player_ids:
|
||||
self._async_update_player_ids(update_result.updated_player_ids)
|
||||
|
||||
async def _async_on_auth_failure(self) -> None:
|
||||
"""Handle when the user credentials are no longer valid."""
|
||||
assert self.config_entry is not None
|
||||
@ -147,8 +170,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Handle a controller event, such as players or groups changed."""
|
||||
if event == const.EVENT_PLAYERS_CHANGED:
|
||||
assert data is not None
|
||||
if data.updated_player_ids:
|
||||
self._async_update_player_ids(data.updated_player_ids)
|
||||
self._async_handle_player_update_result(data)
|
||||
elif (
|
||||
event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
|
||||
and not self._update_sources_pending
|
||||
@ -242,9 +264,7 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
|
||||
except HeosError as error:
|
||||
_LOGGER.error("Unable to refresh players: %s", error)
|
||||
return
|
||||
# After reconnecting, player_id may have changed
|
||||
if player_updates.updated_player_ids:
|
||||
self._async_update_player_ids(player_updates.updated_player_ids)
|
||||
self._async_handle_player_update_result(player_updates)
|
||||
|
||||
@callback
|
||||
def async_get_source_list(self) -> list[str]:
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from collections.abc import Awaitable, Callable, Coroutine, Sequence
|
||||
from datetime import datetime
|
||||
from functools import reduce, wraps
|
||||
from operator import ior
|
||||
@ -93,11 +93,16 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Add media players for a config entry."""
|
||||
devices = [
|
||||
HeosMediaPlayer(entry.runtime_data, player)
|
||||
for player in entry.runtime_data.heos.players.values()
|
||||
]
|
||||
async_add_entities(devices)
|
||||
|
||||
def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
|
||||
"""Add entities for each player."""
|
||||
async_add_entities(
|
||||
[HeosMediaPlayer(entry.runtime_data, player) for player in players]
|
||||
)
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
coordinator.async_add_platform_callback(add_entities_callback)
|
||||
add_entities_callback(list(coordinator.heos.players.values()))
|
||||
|
||||
|
||||
type _FuncType[**_P] = Callable[_P, Awaitable[Any]]
|
||||
|
@ -49,7 +49,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@ -57,8 +57,8 @@ rules:
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
repair-issues: done
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
|
@ -35,7 +35,7 @@ class SW16Entity(Entity):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return bool(self._client.is_connected)
|
||||
|
||||
@ -44,7 +44,7 @@ class SW16Entity(Entity):
|
||||
"""Update availability state."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register update callback."""
|
||||
self._client.register_status_callback(
|
||||
self.handle_event_callback, self._device_port
|
||||
|
@ -62,7 +62,7 @@ class HMDevice(Entity):
|
||||
if self._state:
|
||||
self._state = self._state.upper()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Load data init callbacks."""
|
||||
self._subscribe_homematic_events()
|
||||
|
||||
@ -77,7 +77,7 @@ class HMDevice(Entity):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return true if device is available."""
|
||||
return self._available
|
||||
|
||||
|
@ -54,7 +54,7 @@ class IHCEntity(Entity):
|
||||
self.ihc_note = ""
|
||||
self.ihc_position = ""
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add callback for IHC changes."""
|
||||
_LOGGER.debug("Adding IHC entity notify event: %s", self.ihc_id)
|
||||
self.ihc_controller.add_notify_event(self.ihc_id, self.on_ihc_change, True)
|
||||
|
@ -109,7 +109,7 @@ class InsteonEntity(Entity):
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register INSTEON update events."""
|
||||
_LOGGER.debug(
|
||||
"Tracking updates for device %s group %d name %s",
|
||||
@ -137,7 +137,7 @@ class InsteonEntity(Entity):
|
||||
)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe to INSTEON update events."""
|
||||
_LOGGER.debug(
|
||||
"Remove tracking updates for device %s group %d name %s",
|
||||
|
@ -106,7 +106,7 @@ class ISYNodeEntity(ISYEntity):
|
||||
return getattr(self._node, TAG_ENABLED, True)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Get the state attributes for the device.
|
||||
|
||||
The 'aux_properties' in the pyisy Node class are combined with the
|
||||
@ -189,7 +189,7 @@ class ISYProgramEntity(ISYEntity):
|
||||
self._actions = actions
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict:
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Get the state attributes for the device."""
|
||||
attr = {}
|
||||
if self._actions:
|
||||
|
@ -407,6 +407,12 @@
|
||||
},
|
||||
"power_level_for_location": {
|
||||
"default": "mdi:radiator"
|
||||
},
|
||||
"cycle_count": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
"cycle_count_for_location": {
|
||||
"default": "mdi:counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -248,6 +248,24 @@ TEMPERATURE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key=ThinQProperty.CURRENT_TEMPERATURE,
|
||||
),
|
||||
ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE: SensorEntityDescription(
|
||||
key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
|
||||
key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
|
||||
),
|
||||
ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
|
||||
key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
|
||||
),
|
||||
}
|
||||
WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
|
||||
ThinQProperty.USED_TIME: SensorEntityDescription(
|
||||
@ -341,6 +359,10 @@ TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
|
||||
}
|
||||
|
||||
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key=ThinQProperty.CYCLE_COUNT,
|
||||
translation_key=ThinQProperty.CYCLE_COUNT,
|
||||
),
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
|
||||
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
|
||||
@ -470,6 +492,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
|
||||
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
|
||||
),
|
||||
DeviceType.STYLER: WASHER_SENSORS,
|
||||
DeviceType.SYSTEM_BOILER: (
|
||||
TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE],
|
||||
TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE],
|
||||
TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE],
|
||||
),
|
||||
DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS,
|
||||
DeviceType.WASHCOMBO_MINI: WASHER_SENSORS,
|
||||
DeviceType.WASHER: WASHER_SENSORS,
|
||||
|
@ -305,6 +305,15 @@
|
||||
"current_temperature": {
|
||||
"name": "Current temperature"
|
||||
},
|
||||
"room_air_current_temperature": {
|
||||
"name": "Indoor temperature"
|
||||
},
|
||||
"room_in_water_current_temperature": {
|
||||
"name": "Inlet temperature"
|
||||
},
|
||||
"room_out_water_current_temperature": {
|
||||
"name": "Outlet temperature"
|
||||
},
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
@ -848,6 +857,12 @@
|
||||
},
|
||||
"power_level_for_location": {
|
||||
"name": "{location} power level"
|
||||
},
|
||||
"cycle_count": {
|
||||
"name": "Cycles"
|
||||
},
|
||||
"cycle_count_for_location": {
|
||||
"name": "{location} cycles"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
@ -19,7 +19,7 @@ from aiolookin import (
|
||||
)
|
||||
from aiolookin.models import UDPCommandType, UDPEvent
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@ -192,12 +192,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER]
|
||||
await manager.async_stop()
|
||||
return unload_ok
|
||||
|
@ -18,7 +18,7 @@ class LupusecDevice(Entity):
|
||||
self._device = device
|
||||
self._attr_unique_id = device.device_id
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
"""Update automation state."""
|
||||
self._device.refresh()
|
||||
|
||||
|
@ -63,7 +63,7 @@ class LutronCasetaEntity(Entity):
|
||||
info[ATTR_SUGGESTED_AREA] = area
|
||||
self._attr_device_info = info
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
|
||||
|
||||
|
@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from motionblinds import AsyncMotionMulticast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@ -124,12 +124,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# No motion gateways left, stop Motion multicast
|
||||
unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP)
|
||||
unsub_stop()
|
||||
|
@ -62,6 +62,7 @@ MODELS_V2 = [
|
||||
"RBR",
|
||||
"RBS",
|
||||
"RBW",
|
||||
"RS",
|
||||
"LBK",
|
||||
"LBR",
|
||||
"CBK",
|
||||
|
@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar
|
||||
import eternalegypt
|
||||
from eternalegypt.eternalegypt import SMS
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@ -117,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
hass.data.pop(DOMAIN, None)
|
||||
for service_name in hass.services.async_services()[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.",
|
||||
"description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.",
|
||||
"title": "Connect to the National Weather Service",
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
@ -30,12 +30,12 @@
|
||||
},
|
||||
"services": {
|
||||
"get_forecasts_extra": {
|
||||
"name": "Get extra forecasts data.",
|
||||
"description": "Get extra data for weather forecasts.",
|
||||
"name": "Get extra forecasts data",
|
||||
"description": "Retrieves extra data for weather forecasts.",
|
||||
"fields": {
|
||||
"type": {
|
||||
"name": "Forecast type",
|
||||
"description": "Forecast type: hourly or twice_daily."
|
||||
"description": "The scope of the weather forecast."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,9 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"preconditioning_duration": {
|
||||
"default": "mdi:fan-clock"
|
||||
},
|
||||
"target_percentage": {
|
||||
"default": "mdi:battery-heart"
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from ohme import ApiException, OhmeApiClient
|
||||
|
||||
from homeassistant.components.number import NumberEntity, NumberEntityDescription
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@ -37,6 +37,18 @@ NUMBER_DESCRIPTION = [
|
||||
native_step=1,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
OhmeNumberDescription(
|
||||
key="preconditioning_duration",
|
||||
translation_key="preconditioning_duration",
|
||||
value_fn=lambda client: client.preconditioning,
|
||||
set_fn=lambda client, value: client.async_set_target(
|
||||
pre_condition_length=value
|
||||
),
|
||||
native_min_value=0,
|
||||
native_max_value=60,
|
||||
native_step=5,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
@ -51,6 +51,9 @@
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"preconditioning_duration": {
|
||||
"name": "Preconditioning duration"
|
||||
},
|
||||
"target_percentage": {
|
||||
"name": "Target percentage"
|
||||
}
|
||||
|
@ -33,7 +33,6 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import OnboardingData, OnboardingStorage, OnboardingStoreData
|
||||
@ -235,22 +234,21 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
|
||||
):
|
||||
onboard_integrations.append("rpi_power")
|
||||
|
||||
coros: list[Coroutine[Any, Any, Any]] = [
|
||||
hass.config_entries.flow.async_init(
|
||||
domain, context={"source": "onboarding"}
|
||||
for domain in onboard_integrations:
|
||||
# Create tasks so onboarding isn't affected
|
||||
# by errors in these integrations.
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
domain, context={"source": "onboarding"}
|
||||
),
|
||||
f"onboarding_setup_{domain}",
|
||||
)
|
||||
for domain in onboard_integrations
|
||||
]
|
||||
|
||||
if "analytics" not in hass.config.components:
|
||||
# If by some chance that analytics has not finished
|
||||
# setting up, wait for it here so its ready for the
|
||||
# next step.
|
||||
coros.append(async_setup_component(hass, "analytics", {}))
|
||||
|
||||
# Set up integrations after onboarding and ensure
|
||||
# analytics is ready for the next step.
|
||||
await asyncio.gather(*(create_eager_task(coro) for coro in coros))
|
||||
await async_setup_component(hass, "analytics", {})
|
||||
|
||||
return self.json({})
|
||||
|
||||
|
@ -36,7 +36,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = (
|
||||
key="total_size",
|
||||
value_fn=lambda quota: quota.total,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=0,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@ -46,7 +46,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = (
|
||||
key="used_size",
|
||||
value_fn=lambda quota: quota.used,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
@ -55,7 +55,7 @@ DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = (
|
||||
key="remaining_size",
|
||||
value_fn=lambda quota: quota.remaining,
|
||||
native_unit_of_measurement=UnitOfInformation.BYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
|
||||
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
|
||||
suggested_display_precision=2,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
|
@ -32,11 +32,11 @@
|
||||
"issues": {
|
||||
"drive_full": {
|
||||
"title": "OneDrive data cap exceeded",
|
||||
"description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB."
|
||||
"description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
|
||||
},
|
||||
"drive_almost_full": {
|
||||
"title": "OneDrive near data cap",
|
||||
"description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB."
|
||||
"description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity):
|
||||
self.device: ONVIFDevice = device
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if device is available."""
|
||||
return self.device.available
|
||||
|
||||
|
@ -385,7 +385,7 @@
|
||||
},
|
||||
"set_central_heating_ovrd": {
|
||||
"name": "Set central heating override",
|
||||
"description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.",
|
||||
"description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.",
|
||||
"fields": {
|
||||
"gateway_id": {
|
||||
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
|
||||
@ -393,7 +393,7 @@
|
||||
},
|
||||
"ch_override": {
|
||||
"name": "Central heating override",
|
||||
"description": "The desired boolean value for the central heating override."
|
||||
"description": "Whether to enable or disable the override."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -5,7 +5,7 @@
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
},
|
||||
"description": "Provide URL for the Open Thread Border Router's REST API"
|
||||
"description": "Provide URL for the OpenThread Border Router's REST API"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@ -20,8 +20,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"get_get_border_agent_id_unsupported": {
|
||||
"title": "The OTBR does not support border agent ID",
|
||||
"description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR."
|
||||
"title": "The OTBR does not support Border Agent ID",
|
||||
"description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR."
|
||||
},
|
||||
"insecure_thread_network": {
|
||||
"title": "Insecure Thread network settings detected",
|
||||
|
@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity):
|
||||
|
||||
self._brightness = 255
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
if state := await self.async_get_last_state():
|
||||
@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity):
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return True if unable to access real state of the entity."""
|
||||
return True
|
||||
|
||||
|
@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return if sensor is available."""
|
||||
if self._coordinator is not None:
|
||||
return self._coordinator.last_update_success
|
||||
return True
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
if self._coordinator is not None:
|
||||
self.async_on_remove(
|
||||
|
@ -52,7 +52,7 @@ class MinutPointEntity(Entity):
|
||||
)
|
||||
await self._update_callback()
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect dispatcher listener when removed."""
|
||||
if self._async_unsub_dispatcher_connect:
|
||||
self._async_unsub_dispatcher_connect()
|
||||
@ -61,7 +61,7 @@ class MinutPointEntity(Entity):
|
||||
"""Update the value of the sensor."""
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return true if device is not offline."""
|
||||
return self._client.is_available(self.device_id)
|
||||
|
||||
|
@ -53,7 +53,7 @@
|
||||
"connection_status": {
|
||||
"name": "Connection status",
|
||||
"state": {
|
||||
"connected": "Conencted",
|
||||
"connected": "Connected",
|
||||
"firewalled": "Firewalled",
|
||||
"disconnected": "Disconnected"
|
||||
}
|
||||
@ -109,16 +109,16 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_device": {
|
||||
"message": "No device with id {device_id} was found"
|
||||
"message": "No device with ID {device_id} was found"
|
||||
},
|
||||
"invalid_entry_id": {
|
||||
"message": "No entry with id {device_id} was found"
|
||||
"message": "No entry with ID {device_id} was found"
|
||||
},
|
||||
"login_error": {
|
||||
"message": "A login error occured. Please check you username and password."
|
||||
"message": "A login error occured. Please check your username and password."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can't connect to QBittorrent, please check your configuration."
|
||||
"message": "Can't connect to qBittorrent, please check your configuration."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ class QSEntity(Entity):
|
||||
"""Receive update packet from QSUSB. Match dispather_send signature."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Listen for updates from QSUSb via dispatcher."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(self.hass, self.qsid, self.update_packet)
|
||||
|
@ -45,7 +45,7 @@ class RainCloudEntity(Entity):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
|
@ -13,7 +13,7 @@ from regenmaschine.controller import Controller
|
||||
from regenmaschine.errors import RainMachineError, UnknownAPICallError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_ID,
|
||||
CONF_IP_ADDRESS,
|
||||
@ -465,12 +465,7 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload an RainMachine config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state is ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# If this is the last loaded instance of RainMachine, deregister any services
|
||||
# defined during integration setup:
|
||||
for service_name in (
|
||||
|
@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
|
||||
key="energy",
|
||||
translation_key="this_month_energy",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
subkey="mConsume",
|
||||
@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
|
||||
key="energy_returned",
|
||||
translation_key="this_month_energy_returned",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_display_precision=2,
|
||||
subkey="mConsume",
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from rtmapi import Rtm
|
||||
import voluptuous as vol
|
||||
@ -160,56 +160,64 @@ class RememberTheMilkConfiguration:
|
||||
This class stores the authentication token it get from the backend.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Create new instance of configuration."""
|
||||
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
|
||||
if not os.path.isfile(self._config_file_path):
|
||||
self._config = {}
|
||||
return
|
||||
self._config = {}
|
||||
_LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
|
||||
try:
|
||||
_LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
|
||||
with open(self._config_file_path, encoding="utf8") as config_file:
|
||||
self._config = json.load(config_file)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Failed to load configuration file, creating a new one: %s",
|
||||
self._config = json.loads(
|
||||
Path(self._config_file_path).read_text(encoding="utf8")
|
||||
)
|
||||
except FileNotFoundError:
|
||||
_LOGGER.debug("Missing configuration file: %s", self._config_file_path)
|
||||
except OSError:
|
||||
_LOGGER.debug(
|
||||
"Failed to read from configuration file, %s, using empty configuration",
|
||||
self._config_file_path,
|
||||
)
|
||||
except ValueError:
|
||||
_LOGGER.error(
|
||||
"Failed to parse configuration file, %s, using empty configuration",
|
||||
self._config_file_path,
|
||||
)
|
||||
self._config = {}
|
||||
|
||||
def save_config(self):
|
||||
def _save_config(self) -> None:
|
||||
"""Write the configuration to a file."""
|
||||
with open(self._config_file_path, "w", encoding="utf8") as config_file:
|
||||
json.dump(self._config, config_file)
|
||||
Path(self._config_file_path).write_text(
|
||||
json.dumps(self._config), encoding="utf8"
|
||||
)
|
||||
|
||||
def get_token(self, profile_name):
|
||||
def get_token(self, profile_name: str) -> str | None:
|
||||
"""Get the server token for a profile."""
|
||||
if profile_name in self._config:
|
||||
return self._config[profile_name][CONF_TOKEN]
|
||||
return None
|
||||
|
||||
def set_token(self, profile_name, token):
|
||||
def set_token(self, profile_name: str, token: str) -> None:
|
||||
"""Store a new server token for a profile."""
|
||||
self._initialize_profile(profile_name)
|
||||
self._config[profile_name][CONF_TOKEN] = token
|
||||
self.save_config()
|
||||
self._save_config()
|
||||
|
||||
def delete_token(self, profile_name):
|
||||
def delete_token(self, profile_name: str) -> None:
|
||||
"""Delete a token for a profile.
|
||||
|
||||
Usually called when the token has expired.
|
||||
"""
|
||||
self._config.pop(profile_name, None)
|
||||
self.save_config()
|
||||
self._save_config()
|
||||
|
||||
def _initialize_profile(self, profile_name):
|
||||
def _initialize_profile(self, profile_name: str) -> None:
|
||||
"""Initialize the data structures for a profile."""
|
||||
if profile_name not in self._config:
|
||||
self._config[profile_name] = {}
|
||||
if CONF_ID_MAP not in self._config[profile_name]:
|
||||
self._config[profile_name][CONF_ID_MAP] = {}
|
||||
|
||||
def get_rtm_id(self, profile_name, hass_id):
|
||||
def get_rtm_id(
|
||||
self, profile_name: str, hass_id: str
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Get the RTM ids for a Home Assistant task ID.
|
||||
|
||||
The id of a RTM tasks consists of the tuple:
|
||||
@ -221,7 +229,14 @@ class RememberTheMilkConfiguration:
|
||||
return None
|
||||
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
|
||||
|
||||
def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_task_id):
|
||||
def set_rtm_id(
|
||||
self,
|
||||
profile_name: str,
|
||||
hass_id: str,
|
||||
list_id: str,
|
||||
time_series_id: str,
|
||||
rtm_task_id: str,
|
||||
) -> None:
|
||||
"""Add/Update the RTM task ID for a Home Assistant task IS."""
|
||||
self._initialize_profile(profile_name)
|
||||
id_tuple = {
|
||||
@ -230,11 +245,11 @@ class RememberTheMilkConfiguration:
|
||||
CONF_TASK_ID: rtm_task_id,
|
||||
}
|
||||
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
|
||||
self.save_config()
|
||||
self._save_config()
|
||||
|
||||
def delete_rtm_id(self, profile_name, hass_id):
|
||||
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
|
||||
"""Delete a key mapping."""
|
||||
self._initialize_profile(profile_name)
|
||||
if hass_id in self._config[profile_name][CONF_ID_MAP]:
|
||||
del self._config[profile_name][CONF_ID_MAP][hass_id]
|
||||
self.save_config()
|
||||
self._save_config()
|
||||
|
@ -105,12 +105,12 @@ class RflinkDevice(Entity):
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
def assumed_state(self) -> bool:
|
||||
"""Assume device state until first device event sets state."""
|
||||
return self._state is None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
@ -120,7 +120,7 @@ class RflinkDevice(Entity):
|
||||
self._available = availability
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register update callback."""
|
||||
await super().async_added_to_hass()
|
||||
# Remove temporary bogus entity_id if added
|
||||
@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice):
|
||||
class SwitchableRflinkDevice(RflinkCommand, RestoreEntity):
|
||||
"""Rflink entity which can switch on/off (eg: light, switch)."""
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore RFLink device state (ON/OFF)."""
|
||||
await super().async_added_to_hass()
|
||||
if (old_state := await self.async_get_last_state()) is not None:
|
||||
|
@ -80,7 +80,7 @@ class IRobotEntity(Entity):
|
||||
return None
|
||||
return dt_util.utc_from_timestamp(ts)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callback function."""
|
||||
self.vacuum.register_on_message_callback(self.on_message)
|
||||
|
||||
|
@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sense",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["sense_energy"],
|
||||
"requirements": ["sense-energy==0.13.4"]
|
||||
"requirements": ["sense-energy==0.13.5"]
|
||||
}
|
||||
|
@ -429,16 +429,16 @@
|
||||
}
|
||||
},
|
||||
"enable_pure_boost": {
|
||||
"name": "Enable pure boost",
|
||||
"name": "Enable Pure Boost",
|
||||
"description": "Enables and configures Pure Boost settings.",
|
||||
"fields": {
|
||||
"ac_integration": {
|
||||
"name": "AC integration",
|
||||
"description": "Integrate with Air Conditioner."
|
||||
"description": "Integrate with air conditioner."
|
||||
},
|
||||
"geo_integration": {
|
||||
"name": "Geo integration",
|
||||
"description": "Integrate with Presence."
|
||||
"description": "Integrate with presence."
|
||||
},
|
||||
"indoor_integration": {
|
||||
"name": "Indoor air quality",
|
||||
@ -468,7 +468,7 @@
|
||||
},
|
||||
"fan_mode": {
|
||||
"name": "Fan mode",
|
||||
"description": "set fan mode."
|
||||
"description": "Set fan mode."
|
||||
},
|
||||
"swing_mode": {
|
||||
"name": "Swing mode",
|
||||
|
@ -19,7 +19,7 @@
|
||||
"delivered": {
|
||||
"default": "mdi:package"
|
||||
},
|
||||
"returned": {
|
||||
"alert": {
|
||||
"default": "mdi:package"
|
||||
},
|
||||
"package": {
|
||||
|
@ -11,7 +11,7 @@ get_packages:
|
||||
- "ready_to_be_picked_up"
|
||||
- "undelivered"
|
||||
- "delivered"
|
||||
- "returned"
|
||||
- "alert"
|
||||
translation_key: package_state
|
||||
config_entry_id:
|
||||
required: true
|
||||
|
@ -57,8 +57,8 @@
|
||||
"delivered": {
|
||||
"name": "Delivered"
|
||||
},
|
||||
"returned": {
|
||||
"name": "Returned"
|
||||
"alert": {
|
||||
"name": "Alert"
|
||||
},
|
||||
"package": {
|
||||
"name": "Package {name}"
|
||||
@ -68,7 +68,7 @@
|
||||
"services": {
|
||||
"get_packages": {
|
||||
"name": "Get packages",
|
||||
"description": "Get packages from 17Track",
|
||||
"description": "Queries the 17track API for the latest package data.",
|
||||
"fields": {
|
||||
"package_state": {
|
||||
"name": "Package states",
|
||||
@ -82,7 +82,7 @@
|
||||
},
|
||||
"archive_package": {
|
||||
"name": "Archive package",
|
||||
"description": "Archive a package",
|
||||
"description": "Archives a package using the 17track API.",
|
||||
"fields": {
|
||||
"package_tracking_number": {
|
||||
"name": "Package tracking number",
|
||||
@ -104,7 +104,7 @@
|
||||
"ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]",
|
||||
"undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]",
|
||||
"delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]",
|
||||
"returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]"
|
||||
"alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ from simplipy.websocket import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_CODE,
|
||||
ATTR_DEVICE_ID,
|
||||
@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# If this is the last loaded instance of SimpliSafe, deregister any services
|
||||
# defined during integration setup:
|
||||
for service_name in SERVICES:
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pymodbus", "pysmarty2"],
|
||||
"requirements": ["pysmarty2==0.10.1"]
|
||||
"requirements": ["pysmarty2==0.10.2"]
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Set up SMLIGHT Zigbee Integration",
|
||||
"description": "Set up SMLIGHT Zigbee integration",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
@ -111,7 +111,7 @@
|
||||
"name": "Zigbee flash mode"
|
||||
},
|
||||
"reconnect_zigbee_router": {
|
||||
"name": "Reconnect zigbee router"
|
||||
"name": "Reconnect Zigbee router"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@ -71,7 +71,7 @@ class SomaEntity(Entity):
|
||||
self.api_is_available = True
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return true if the last API commands returned successfully."""
|
||||
return self.is_available
|
||||
|
||||
|
@ -129,10 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
|
||||
|
||||
server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms)
|
||||
|
||||
entry.runtime_data = SqueezeboxData(
|
||||
coordinator=server_coordinator,
|
||||
server=lms,
|
||||
)
|
||||
entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms)
|
||||
|
||||
# set up player discovery
|
||||
known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {})
|
||||
|
@ -81,11 +81,12 @@ CONTENT_TYPE_TO_CHILD_TYPE = {
|
||||
"New Music": MediaType.ALBUM,
|
||||
}
|
||||
|
||||
BROWSE_LIMIT = 1000
|
||||
|
||||
|
||||
async def build_item_response(
|
||||
entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None]
|
||||
entity: MediaPlayerEntity,
|
||||
player: Player,
|
||||
payload: dict[str, str | None],
|
||||
browse_limit: int,
|
||||
) -> BrowseMedia:
|
||||
"""Create response payload for search described by payload."""
|
||||
|
||||
@ -107,7 +108,7 @@ async def build_item_response(
|
||||
|
||||
result = await player.async_browse(
|
||||
MEDIA_TYPE_TO_SQUEEZEBOX[search_type],
|
||||
limit=BROWSE_LIMIT,
|
||||
limit=browse_limit,
|
||||
browse_id=browse_id,
|
||||
)
|
||||
|
||||
@ -237,7 +238,11 @@ def media_source_content_filter(item: BrowseMedia) -> bool:
|
||||
return item.media_content_type.startswith("audio/")
|
||||
|
||||
|
||||
async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None:
|
||||
async def generate_playlist(
|
||||
player: Player,
|
||||
payload: dict[str, str],
|
||||
browse_limit: int,
|
||||
) -> list | None:
|
||||
"""Generate playlist from browsing payload."""
|
||||
media_type = payload["search_type"]
|
||||
media_id = payload["search_id"]
|
||||
@ -247,7 +252,7 @@ async def generate_playlist(player: Player, payload: dict[str, str]) -> list | N
|
||||
|
||||
browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id)
|
||||
result = await player.async_browse(
|
||||
"titles", limit=BROWSE_LIMIT, browse_id=browse_id
|
||||
"titles", limit=browse_limit, browse_id=browse_id
|
||||
)
|
||||
if result and "items" in result:
|
||||
items: list = result["items"]
|
||||
|
@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN
|
||||
from .const import (
|
||||
CONF_BROWSE_LIMIT,
|
||||
CONF_HTTPS,
|
||||
CONF_VOLUME_STEP,
|
||||
DEFAULT_BROWSE_LIMIT,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_VOLUME_STEP,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.data_schema = _base_schema()
|
||||
self.discovery_info: dict[str, Any] | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
async def _discover(self, uuid: str | None = None) -> None:
|
||||
"""Discover an unconfigured LMS server."""
|
||||
self.discovery_info = None
|
||||
@ -222,3 +247,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# if the player is unknown, then we likely need to configure its server
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BROWSE_LIMIT): vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
vol.Required(CONF_VOLUME_STEP): vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OptionsFlowHandler(OptionsFlow):
|
||||
"""Options Flow Handler."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Options Flow Steps."""
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
OPTIONS_SCHEMA,
|
||||
{
|
||||
CONF_BROWSE_LIMIT: self.config_entry.options.get(
|
||||
CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
|
||||
),
|
||||
CONF_VOLUME_STEP: self.config_entry.options.get(
|
||||
CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
@ -32,3 +32,7 @@ SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered"
|
||||
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
|
||||
DISCOVERY_INTERVAL = 60
|
||||
PLAYER_UPDATE_INTERVAL = 5
|
||||
CONF_BROWSE_LIMIT = "browse_limit"
|
||||
CONF_VOLUME_STEP = "volume_step"
|
||||
DEFAULT_BROWSE_LIMIT = 1000
|
||||
DEFAULT_VOLUME_STEP = 5
|
||||
|
@ -52,6 +52,10 @@ from .browse_media import (
|
||||
media_source_content_filter,
|
||||
)
|
||||
from .const import (
|
||||
CONF_BROWSE_LIMIT,
|
||||
CONF_VOLUME_STEP,
|
||||
DEFAULT_BROWSE_LIMIT,
|
||||
DEFAULT_VOLUME_STEP,
|
||||
DISCOVERY_TASK,
|
||||
DOMAIN,
|
||||
KNOWN_PLAYERS,
|
||||
@ -166,6 +170,7 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
@ -184,10 +189,7 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
_attr_name = None
|
||||
_last_update: datetime | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: SqueezeBoxPlayerUpdateCoordinator,
|
||||
) -> None:
|
||||
def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None:
|
||||
"""Initialize the SqueezeBox device."""
|
||||
super().__init__(coordinator)
|
||||
player = coordinator.player
|
||||
@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
self._last_update = utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def volume_step(self) -> float:
|
||||
"""Return the step to be used for volume up down."""
|
||||
return float(
|
||||
self.coordinator.config_entry.options.get(
|
||||
CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
|
||||
)
|
||||
/ 100
|
||||
)
|
||||
|
||||
@property
|
||||
def browse_limit(self) -> int:
|
||||
"""Return the step to be used for volume up down."""
|
||||
return self.coordinator.config_entry.options.get(
|
||||
CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
await self._player.async_set_power(False)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up media player."""
|
||||
await self._player.async_set_volume("+5")
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down media player."""
|
||||
await self._player.async_set_volume("-5")
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
volume_percent = str(int(volume * 100))
|
||||
@ -466,7 +475,11 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
"search_id": media_id,
|
||||
"search_type": MediaType.PLAYLIST,
|
||||
}
|
||||
playlist = await generate_playlist(self._player, payload)
|
||||
playlist = await generate_playlist(
|
||||
self._player,
|
||||
payload,
|
||||
self.browse_limit,
|
||||
)
|
||||
except BrowseError:
|
||||
# a list of urls
|
||||
content = json.loads(media_id)
|
||||
@ -477,7 +490,11 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
"search_id": media_id,
|
||||
"search_type": media_type,
|
||||
}
|
||||
playlist = await generate_playlist(self._player, payload)
|
||||
playlist = await generate_playlist(
|
||||
self._player,
|
||||
payload,
|
||||
self.browse_limit,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Generated playlist: %s", playlist)
|
||||
|
||||
@ -587,7 +604,12 @@ class SqueezeBoxMediaPlayerEntity(
|
||||
"search_id": media_content_id,
|
||||
}
|
||||
|
||||
return await build_item_response(self, self._player, payload)
|
||||
return await build_item_response(
|
||||
self,
|
||||
self._player,
|
||||
payload,
|
||||
self.browse_limit,
|
||||
)
|
||||
|
||||
async def async_get_browse_image(
|
||||
self,
|
||||
|
@ -103,5 +103,20 @@
|
||||
"unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "LMS Configuration",
|
||||
"data": {
|
||||
"browse_limit": "Browse limit",
|
||||
"volume_step": "Volume step"
|
||||
},
|
||||
"data_description": {
|
||||
"browse_limit": "Maximum number of items when browsing or in a playlist.",
|
||||
"volume_step": "Amount to adjust the volume when turning volume up or down."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,20 +27,20 @@ class StarlineEntity(Entity):
|
||||
self._unsubscribe_api: Callable | None = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._account.api.available
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
"""Read new state data."""
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity about to be added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self._unsubscribe_api = self._account.api.add_update_listener(self.update)
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity is being removed from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._unsubscribe_api is not None:
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["stookwijzer==1.5.2"]
|
||||
"requirements": ["stookwijzer==1.5.4"]
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ class SwitchbotEntity(
|
||||
return self.coordinator.device.parsed_data
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[Any, Any]:
|
||||
def extra_state_attributes(self) -> Mapping[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
return {"last_run_success": self._last_run_success}
|
||||
|
||||
|
@ -14,6 +14,7 @@ from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@ -101,6 +102,7 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
)
|
||||
syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
|
||||
self.api = syno_data.api
|
||||
self.backup_base_names: dict[str, str] = {}
|
||||
|
||||
@property
|
||||
def _file_station(self) -> SynoFileStation:
|
||||
@ -109,18 +111,19 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
assert self.api.file_station
|
||||
return self.api.file_station
|
||||
|
||||
async def _async_suggested_filenames(
|
||||
async def _async_backup_filenames(
|
||||
self,
|
||||
backup_id: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Suggest filenames for the backup.
|
||||
"""Return the actual backup filenames.
|
||||
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: A tuple of tar_filename and meta_filename
|
||||
"""
|
||||
if (backup := await self.async_get_backup(backup_id)) is None:
|
||||
raise BackupAgentError("Backup not found")
|
||||
return suggested_filenames(backup)
|
||||
if await self.async_get_backup(backup_id) is None:
|
||||
raise BackupNotFound
|
||||
base_name = self.backup_base_names[backup_id]
|
||||
return (f"{base_name}.tar", f"{base_name}_meta.json")
|
||||
|
||||
async def async_download_backup(
|
||||
self,
|
||||
@ -132,7 +135,7 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
:return: An async iterator that yields bytes.
|
||||
"""
|
||||
(filename_tar, _) = await self._async_suggested_filenames(backup_id)
|
||||
(filename_tar, _) = await self._async_backup_filenames(backup_id)
|
||||
|
||||
try:
|
||||
resp = await self._file_station.download_file(
|
||||
@ -193,7 +196,7 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
:param backup_id: The ID of the backup that was returned in async_list_backups.
|
||||
"""
|
||||
try:
|
||||
(filename_tar, filename_meta) = await self._async_suggested_filenames(
|
||||
(filename_tar, filename_meta) = await self._async_backup_filenames(
|
||||
backup_id
|
||||
)
|
||||
except BackupAgentError:
|
||||
@ -247,6 +250,7 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
assert files
|
||||
|
||||
backups: dict[str, AgentBackup] = {}
|
||||
backup_base_names: dict[str, str] = {}
|
||||
for file in files:
|
||||
if file.name.endswith("_meta.json"):
|
||||
try:
|
||||
@ -255,7 +259,10 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
LOGGER.error("Failed to download meta data: %s", err)
|
||||
continue
|
||||
agent_backup = AgentBackup.from_dict(meta_data)
|
||||
backups[agent_backup.backup_id] = agent_backup
|
||||
backup_id = agent_backup.backup_id
|
||||
backups[backup_id] = agent_backup
|
||||
backup_base_names[backup_id] = file.name.replace("_meta.json", "")
|
||||
self.backup_base_names = backup_base_names
|
||||
return backups
|
||||
|
||||
async def async_get_backup(
|
||||
|
@ -35,13 +35,17 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_BACKUP_PATH,
|
||||
CONF_DEVICE_TOKEN,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
EXCEPTION_DETAILS,
|
||||
EXCEPTION_UNKNOWN,
|
||||
ISSUE_MISSING_BACKUP_SETUP,
|
||||
SYNOLOGY_CONNECTION_EXCEPTIONS,
|
||||
)
|
||||
|
||||
@ -174,6 +178,19 @@ class SynoApi:
|
||||
" permissions or no writable shared folders available"
|
||||
)
|
||||
|
||||
if shares and not self._entry.options.get(CONF_BACKUP_PATH):
|
||||
ir.async_create_issue(
|
||||
self._hass,
|
||||
DOMAIN,
|
||||
f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}",
|
||||
data={"entry_id": self._entry.entry_id},
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_MISSING_BACKUP_SETUP,
|
||||
translation_placeholders={"title": self._entry.title},
|
||||
)
|
||||
|
||||
LOGGER.debug(
|
||||
"State of File Station during setup of '%s': %s",
|
||||
self._entry.unique_id,
|
||||
|
@ -35,6 +35,8 @@ PLATFORMS = [
|
||||
EXCEPTION_DETAILS = "details"
|
||||
EXCEPTION_UNKNOWN = "unknown"
|
||||
|
||||
ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup"
|
||||
|
||||
# Configuration
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_VOLUMES = "volumes"
|
||||
|
125
homeassistant/components/synology_dsm/repairs.py
Normal file
125
homeassistant/components/synology_dsm/repairs.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Repair flows for the Synology DSM integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from synology_dsm.api.file_station.models import SynoFileSharedFolder
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_BACKUP_PATH,
|
||||
CONF_BACKUP_SHARE,
|
||||
DOMAIN,
|
||||
ISSUE_MISSING_BACKUP_SETUP,
|
||||
SYNOLOGY_CONNECTION_EXCEPTIONS,
|
||||
)
|
||||
from .models import SynologyDSMData
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MissingBackupSetupRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, issue_id: str) -> None:
|
||||
"""Create flow."""
|
||||
self.entry = entry
|
||||
self.issue_id = issue_id
|
||||
super().__init__()
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
|
||||
return self.async_show_menu(
|
||||
menu_options=["confirm", "ignore"],
|
||||
description_placeholders={
|
||||
"docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location"
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
|
||||
syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.entry.unique_id]
|
||||
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.entry, options={**dict(self.entry.options), **user_input}
|
||||
)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
shares: list[SynoFileSharedFolder] | None = None
|
||||
if syno_data.api.file_station:
|
||||
with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
|
||||
shares = await syno_data.api.file_station.get_shared_folders(
|
||||
only_writable=True
|
||||
)
|
||||
|
||||
if not shares:
|
||||
return self.async_abort(reason="no_shares")
|
||||
|
||||
return self.async_show_form(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_BACKUP_SHARE,
|
||||
default=self.entry.options[CONF_BACKUP_SHARE],
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(value=s.path, label=s.name)
|
||||
for s in shares
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
),
|
||||
),
|
||||
vol.Required(
|
||||
CONF_BACKUP_PATH,
|
||||
default=self.entry.options[CONF_BACKUP_PATH],
|
||||
): str,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_ignore(
|
||||
self, _: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
|
||||
return self.async_abort(reason="ignored")
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
entry = None
|
||||
if data and (entry_id := data.get("entry_id")):
|
||||
entry_id = cast(str, entry_id)
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
|
||||
if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP):
|
||||
return MissingBackupSetupRepairFlow(entry, issue_id)
|
||||
|
||||
return ConfirmRepairFlow()
|
@ -185,6 +185,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"missing_backup_setup": {
|
||||
"title": "Backup location not configured for {title}",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})",
|
||||
"menu_options": {
|
||||
"confirm": "Set up the backup location now",
|
||||
"ignore": "Don't set it up now"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"title": "[%key:component::synology_dsm::config::step::backup_share::title%]",
|
||||
"data": {
|
||||
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
|
||||
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
|
||||
},
|
||||
"data_description": {
|
||||
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]",
|
||||
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"no_shares": "There are no shared folders available for the user.\nPlease check the documentation.",
|
||||
"ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reboot": {
|
||||
"name": "Reboot",
|
||||
|
@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity):
|
||||
self._id = device_id
|
||||
self._client = client
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
_LOGGER.debug("Created device %s", self)
|
||||
self.async_on_remove(
|
||||
@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity):
|
||||
return self.device.state
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return true if unable to access real state of entity."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Return true if device is not offline."""
|
||||
return self._client.is_available(self.device_id)
|
||||
|
||||
|
@ -40,7 +40,7 @@ class TellstickDevice(Entity):
|
||||
self._attr_name = tellcore_device.name
|
||||
self._attr_unique_id = tellcore_device.id
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
@ -146,6 +146,6 @@ class TellstickDevice(Entity):
|
||||
except TelldusError as err:
|
||||
_LOGGER.error(err)
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
"""Poll the current state of the device."""
|
||||
self._update_from_tellcore()
|
||||
|
@ -712,7 +712,7 @@
|
||||
"name": "Navigate to coordinates"
|
||||
},
|
||||
"set_scheduled_charging": {
|
||||
"description": "Sets a time at which charging should be completed.",
|
||||
"description": "Sets a time at which charging should be started.",
|
||||
"fields": {
|
||||
"device_id": {
|
||||
"description": "Vehicle to schedule.",
|
||||
|
@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import (
|
||||
UnsupportedControllerVersion,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
loaded_entries = [
|
||||
entry
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# This is the last loaded instance of Omada, deregister any services
|
||||
hass.services.async_remove(DOMAIN, "reconnect_client")
|
||||
|
||||
|
@ -30,7 +30,7 @@ class UpbEntity(Entity):
|
||||
return self._element.as_dict()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""Is the entity available to be updated."""
|
||||
return self._upb.is_connected()
|
||||
|
||||
@ -43,7 +43,7 @@ class UpbEntity(Entity):
|
||||
self._element_changed(element, changeset)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callback for UPB changes and update entity state."""
|
||||
self._element.add_callback(self._element_callback)
|
||||
self._element_callback(self._element, {})
|
||||
|
@ -31,6 +31,6 @@ class VeluxEntity(Entity):
|
||||
|
||||
self.node.register_device_updated_cb(after_update_callback)
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Store register state change callback."""
|
||||
self.async_register_callbacks()
|
||||
|
@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
|
||||
"""Update the state."""
|
||||
self.schedule_update_ha_state(True)
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
"""Force a refresh from the device if the device is unavailable."""
|
||||
refresh_needed = self.vera_device.should_poll or not self.available
|
||||
_LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed)
|
||||
@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
|
||||
return attr
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
def available(self) -> bool:
|
||||
"""If device communications have failed return false."""
|
||||
return not self.vera_device.comm_failure
|
||||
|
||||
|
@ -63,6 +63,7 @@ SKU_TO_BASE_DEVICE = {
|
||||
# Air Purifiers
|
||||
"LV-PUR131S": "LV-PUR131S",
|
||||
"LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S
|
||||
"LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S
|
||||
"Core200S": "Core200S",
|
||||
"LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S
|
||||
"LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S
|
||||
|
@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"requirements": ["pyvesync==2.1.17"]
|
||||
"requirements": ["pyvesync==2.1.18"]
|
||||
}
|
||||
|
@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/vicare",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["PyViCare"],
|
||||
"requirements": ["PyViCare==2.43.0"]
|
||||
"requirements": ["PyViCare==2.43.1"]
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]):
|
||||
return f"{self._vehicle_name} {self._entity_name}"
|
||||
|
||||
@property
|
||||
def assumed_state(self):
|
||||
def assumed_state(self) -> bool:
|
||||
"""Return true if unable to access real state of entity."""
|
||||
return True
|
||||
|
||||
|
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