mirror of
https://github.com/home-assistant/core.git
synced 2026-05-12 05:44:27 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc5cfbd61 | |||
| d8fcbd56e4 | |||
| 0feff00e1a | |||
| dfaa57d7cb | |||
| 063a843f4c | |||
| 2dce45888c | |||
| 9b514a4cb1 |
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -139,7 +139,6 @@ homeassistant.components.cambridge_audio.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.casper_glow.*
|
||||
homeassistant.components.centriconnect.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
@@ -156,7 +155,6 @@ homeassistant.components.counter.*
|
||||
homeassistant.components.cover.*
|
||||
homeassistant.components.cpuspeed.*
|
||||
homeassistant.components.crownstone.*
|
||||
homeassistant.components.data_grand_lyon.*
|
||||
homeassistant.components.date.*
|
||||
homeassistant.components.datetime.*
|
||||
homeassistant.components.deako.*
|
||||
@@ -297,7 +295,6 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.indevolt.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
@@ -426,7 +423,6 @@ homeassistant.components.otp.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
homeassistant.components.paj_gps.*
|
||||
homeassistant.components.panel_custom.*
|
||||
homeassistant.components.paperless_ngx.*
|
||||
homeassistant.components.peblar.*
|
||||
|
||||
Generated
-8
@@ -288,16 +288,12 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/cast/ @emontnemery
|
||||
/homeassistant/components/ccm15/ @ocalvo
|
||||
/tests/components/ccm15/ @ocalvo
|
||||
/homeassistant/components/centriconnect/ @gresrun
|
||||
/tests/components/centriconnect/ @gresrun
|
||||
/homeassistant/components/cert_expiry/ @jjlawren
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
/tests/components/chacon_dio/ @cnico
|
||||
/homeassistant/components/chess_com/ @joostlek
|
||||
/tests/components/chess_com/ @joostlek
|
||||
/homeassistant/components/cielo_home/ @ihsan-cielo @mudasar-cielo
|
||||
/tests/components/cielo_home/ @ihsan-cielo @mudasar-cielo
|
||||
/homeassistant/components/cisco_ios/ @fbradyirl
|
||||
/homeassistant/components/cisco_mobility_express/ @fbradyirl
|
||||
/homeassistant/components/cisco_webex_teams/ @fbradyirl
|
||||
@@ -349,8 +345,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/cync/ @Kinachi249
|
||||
/homeassistant/components/daikin/ @fredrike
|
||||
/tests/components/daikin/ @fredrike
|
||||
/homeassistant/components/data_grand_lyon/ @Crocmagnon
|
||||
/tests/components/data_grand_lyon/ @Crocmagnon
|
||||
/homeassistant/components/date/ @home-assistant/core
|
||||
/tests/components/date/ @home-assistant/core
|
||||
/homeassistant/components/datetime/ @home-assistant/core
|
||||
@@ -1314,8 +1308,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/ovo_energy/ @timmo001
|
||||
/homeassistant/components/p1_monitor/ @klaasnicolaas
|
||||
/tests/components/p1_monitor/ @klaasnicolaas
|
||||
/homeassistant/components/paj_gps/ @skipperro
|
||||
/tests/components/paj_gps/ @skipperro
|
||||
/homeassistant/components/palazzetti/ @dotvav
|
||||
/tests/components/palazzetti/ @dotvav
|
||||
/homeassistant/components/panel_custom/ @home-assistant/frontend
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "mitsubishi",
|
||||
"name": "Mitsubishi",
|
||||
"integrations": ["melcloud", "mitsubishi_comfort"]
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.2"]
|
||||
"requirements": ["serialx==1.7.1"]
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: todo
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: done
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: The ozone sensor can still use the ozone device class.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
stale-devices: todo
|
||||
repair-issues: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -46,9 +46,6 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"radius": "Station radius (miles)"
|
||||
},
|
||||
"data_description": {
|
||||
"radius": "The radius in miles around your location to search for reporting stations."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
"requirements": ["aioamazondevices==13.4.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import anthropic
|
||||
from anthropic.resources.messages.messages import DEPRECATED_MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -40,7 +41,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str]
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import cast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
|
||||
@@ -20,14 +21,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
|
||||
raise ValueError("Missing data")
|
||||
self._data = data
|
||||
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_disable_entity()
|
||||
|
||||
async def async_step_confirm_disable_entity(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> RepairsFlowResult:
|
||||
) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
@@ -30,6 +30,7 @@ from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaOptionsFlowHandler,
|
||||
)
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .bridge import AsusWrtBridge
|
||||
from .const import (
|
||||
@@ -141,12 +142,20 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
user_input = self._config_data
|
||||
|
||||
add_schema: VolDictType
|
||||
if self.show_advanced_options:
|
||||
add_schema = {
|
||||
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
|
||||
}
|
||||
else:
|
||||
add_schema = {vol.Required(CONF_PASSWORD): str}
|
||||
|
||||
schema = {
|
||||
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str,
|
||||
vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str,
|
||||
vol.Exclusive(CONF_PASSWORD, PASS_KEY, PASS_KEY_MSG): str,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Exclusive(CONF_SSH_KEY, PASS_KEY, PASS_KEY_MSG): str,
|
||||
**add_schema,
|
||||
vol.Required(
|
||||
CONF_PROTOCOL,
|
||||
default=user_input.get(CONF_PROTOCOL, PROTOCOL_HTTPS),
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==69"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.3"],
|
||||
"requirements": ["blebox-uniapi==2.5.2"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
|
||||
from broadlink.exceptions import BroadlinkException
|
||||
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -43,8 +43,8 @@ async def async_setup_entry(
|
||||
async_add_entities([BroadlinkInfraredEntity(device)])
|
||||
|
||||
|
||||
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEmitterEntity):
|
||||
"""Broadlink infrared emitter entity."""
|
||||
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
|
||||
"""Broadlink infrared transmitter entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "infrared_emitter"
|
||||
|
||||
@@ -38,14 +38,7 @@ from homeassistant.helpers.device_registry import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -125,9 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
|
||||
# Read available heating circuits from config entry data
|
||||
# (populated by config flow or migration)
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
|
||||
DEFAULT_HEATING_CIRCUITS
|
||||
)
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
|
||||
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
@@ -238,7 +229,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
# heating circuits from the device; fall back to [1] (pre-multi-circuit
|
||||
# default) if the device is unreachable or the endpoint is unsupported.
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
circuits: list[int] = [1]
|
||||
config = BSBLANConfig(
|
||||
host=entry.data[CONF_HOST],
|
||||
passkey=entry.data[CONF_PASSKEY],
|
||||
@@ -254,18 +245,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
except (BSBLANError, TimeoutError) as err:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration failed for %s (%s); "
|
||||
"defaulting to a single circuit. Use Reconfigure to "
|
||||
"defaulting to single circuit [1]. Use Reconfigure to "
|
||||
"rediscover additional circuits later",
|
||||
entry.data[CONF_HOST],
|
||||
err,
|
||||
)
|
||||
if not circuits:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration returned no heating circuits "
|
||||
"for %s; defaulting to a single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
@@ -279,22 +263,4 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
circuits,
|
||||
)
|
||||
|
||||
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
|
||||
# discovery. Every BSB-LAN setup has at least one heating circuit.
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if not entry.data[CONF_HEATING_CIRCUITS]:
|
||||
LOGGER.warning(
|
||||
"Stored heating circuits for %s are empty; defaulting to a "
|
||||
"single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
data = {
|
||||
**entry.data,
|
||||
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
|
||||
}
|
||||
else:
|
||||
data = {**entry.data}
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
|
||||
|
||||
return True
|
||||
|
||||
@@ -13,28 +13,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
|
||||
|
||||
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a BSBLAN config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 3
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize BSBLan flow."""
|
||||
self.host: str = ""
|
||||
self.port: int = DEFAULT_PORT
|
||||
self.mac: str | None = None
|
||||
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
self.circuits: list[int] = [1]
|
||||
self.passkey: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
@@ -391,13 +384,6 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
await bsblan.initialize()
|
||||
self.circuits = await bsblan.get_available_circuits()
|
||||
if not self.circuits:
|
||||
LOGGER.debug(
|
||||
"Circuit discovery returned no heating circuits for %s, "
|
||||
"defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
except (
|
||||
BSBLANError,
|
||||
TimeoutError,
|
||||
@@ -406,4 +392,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"Circuit discovery not available for %s, defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
self.circuits = [1]
|
||||
|
||||
@@ -22,5 +22,4 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
|
||||
CONF_PASSKEY: Final = "passkey"
|
||||
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
|
||||
|
||||
DEFAULT_HEATING_CIRCUITS: Final = (1,)
|
||||
DEFAULT_PORT: Final = 80
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.2.1"],
|
||||
"requirements": ["python-bsblan==5.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -20,13 +21,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the initial step of the repair flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle confirmation, remove the bindkey, and reload the entry."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self._entry_id)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Component to embed Google Cast."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from pychromecast import Chromecast
|
||||
from pychromecast.controllers.multizone import MultizoneManager
|
||||
from pychromecast.discovery import CastBrowser
|
||||
|
||||
from homeassistant.components.media_player import BrowseMedia, MediaType
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -23,41 +20,12 @@ from .const import DOMAIN
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
type CastConfigEntry = ConfigEntry[CastRuntimeData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CastRuntimeData:
|
||||
"""Runtime data for the Cast integration."""
|
||||
|
||||
cast_platforms: dict[str, CastProtocol] = field(default_factory=dict)
|
||||
unknown_models: dict[str | None, tuple[str | None, str | None]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
added_cast_devices: set[UUID] = field(default_factory=set)
|
||||
browser: CastBrowser | None = None
|
||||
multizone_manager: MultizoneManager | None = None
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CastConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Cast from a config entry."""
|
||||
entry.runtime_data = CastRuntimeData()
|
||||
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
|
||||
await home_assistant_cast.async_setup_ha_cast(hass, entry)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@callback
|
||||
def _register_cast_platform(
|
||||
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
|
||||
) -> None:
|
||||
"""Register a cast platform."""
|
||||
if (
|
||||
not hasattr(platform, "async_get_media_browser_root_object")
|
||||
or not hasattr(platform, "async_browse_media")
|
||||
or not hasattr(platform, "async_play_media")
|
||||
):
|
||||
raise HomeAssistantError(f"Invalid cast platform {platform}")
|
||||
entry.runtime_data.cast_platforms[integration_domain] = platform
|
||||
|
||||
await async_process_integration_platforms(hass, DOMAIN, _register_cast_platform)
|
||||
return True
|
||||
|
||||
@@ -97,13 +65,27 @@ class CastProtocol(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> None:
|
||||
@callback
|
||||
def _register_cast_platform(
|
||||
hass: HomeAssistant, integration_domain: str, platform: CastProtocol
|
||||
):
|
||||
"""Register a cast platform."""
|
||||
if (
|
||||
not hasattr(platform, "async_get_media_browser_root_object")
|
||||
or not hasattr(platform, "async_browse_media")
|
||||
or not hasattr(platform, "async_play_media")
|
||||
):
|
||||
raise HomeAssistantError(f"Invalid cast platform {platform}")
|
||||
hass.data[DOMAIN]["cast_platform"][integration_domain] = platform
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Remove Home Assistant Cast user."""
|
||||
await home_assistant_cast.async_remove_user(hass, entry)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove cast config entry from a device.
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Config flow for Cast."""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_UUID
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -14,9 +19,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CastConfigEntry
|
||||
|
||||
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
|
||||
KNOWN_HOSTS_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -38,7 +40,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: CastConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
) -> CastOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return CastOptionsFlowHandler()
|
||||
|
||||
@@ -12,6 +12,13 @@ DOMAIN = "cast"
|
||||
|
||||
# Stores a threading.Lock that is held by the internal pychromecast discovery.
|
||||
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
|
||||
# Stores UUIDs of cast devices that were added as entities. Doesn't store
|
||||
# None UUIDs.
|
||||
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
|
||||
# Stores an audio group manager.
|
||||
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
|
||||
# Store a CastBrowser
|
||||
CAST_BROWSER_KEY = "cast_browser"
|
||||
|
||||
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
|
||||
# Chromecast or receive it through configuration
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pychromecast.discovery
|
||||
import pychromecast.models
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import (
|
||||
CAST_BROWSER_KEY,
|
||||
CONF_KNOWN_HOSTS,
|
||||
INTERNAL_DISCOVERY_RUNNING_KEY,
|
||||
SIGNAL_CAST_DISCOVERED,
|
||||
@@ -19,16 +20,11 @@ from .const import (
|
||||
)
|
||||
from .helpers import ChromecastInfo, ChromeCastZeroconf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CastConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def discover_chromecast(
|
||||
hass: HomeAssistant,
|
||||
cast_info: pychromecast.models.CastInfo,
|
||||
config_entry: CastConfigEntry,
|
||||
hass: HomeAssistant, cast_info: pychromecast.models.CastInfo
|
||||
) -> None:
|
||||
"""Discover a Chromecast."""
|
||||
|
||||
@@ -40,7 +36,7 @@ def discover_chromecast(
|
||||
_LOGGER.error("Discovered chromecast without uuid %s", info)
|
||||
return
|
||||
|
||||
info = info.fill_out_missing_chromecast_info(hass, config_entry)
|
||||
info = info.fill_out_missing_chromecast_info(hass)
|
||||
_LOGGER.debug("Discovered new or updated chromecast %s", info)
|
||||
|
||||
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
|
||||
@@ -53,9 +49,7 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
|
||||
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
|
||||
|
||||
|
||||
def setup_internal_discovery(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> None:
|
||||
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Set up the pychromecast internal discovery."""
|
||||
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
|
||||
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
|
||||
@@ -69,11 +63,11 @@ def setup_internal_discovery(
|
||||
|
||||
def add_cast(self, uuid, _):
|
||||
"""Handle zeroconf discovery of a new chromecast."""
|
||||
discover_chromecast(hass, browser.devices[uuid], config_entry)
|
||||
discover_chromecast(hass, browser.devices[uuid])
|
||||
|
||||
def update_cast(self, uuid, _):
|
||||
"""Handle zeroconf discovery of an updated chromecast."""
|
||||
discover_chromecast(hass, browser.devices[uuid], config_entry)
|
||||
discover_chromecast(hass, browser.devices[uuid])
|
||||
|
||||
def remove_cast(self, uuid, service, cast_info):
|
||||
"""Handle zeroconf discovery of a removed chromecast."""
|
||||
@@ -90,7 +84,7 @@ def setup_internal_discovery(
|
||||
ChromeCastZeroconf.get_zeroconf(),
|
||||
config_entry.data.get(CONF_KNOWN_HOSTS),
|
||||
)
|
||||
config_entry.runtime_data.browser = browser
|
||||
hass.data[CAST_BROWSER_KEY] = browser
|
||||
browser.start_discovery()
|
||||
|
||||
def stop_discovery(event):
|
||||
@@ -104,9 +98,7 @@ def setup_internal_discovery(
|
||||
config_entry.add_update_listener(config_entry_updated)
|
||||
|
||||
|
||||
async def config_entry_updated(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> None:
|
||||
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Handle config entry being updated."""
|
||||
if browser := config_entry.runtime_data.browser:
|
||||
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
|
||||
browser = hass.data[CAST_BROWSER_KEY]
|
||||
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
|
||||
|
||||
@@ -20,11 +20,11 @@ import pychromecast.socket_client
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components import zeroconf
|
||||
|
||||
from . import CastConfigEntry
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,16 +56,16 @@ class ChromecastInfo:
|
||||
"""Return the UUID."""
|
||||
return self.cast_info.uuid
|
||||
|
||||
def fill_out_missing_chromecast_info(
|
||||
self, hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> ChromecastInfo:
|
||||
def fill_out_missing_chromecast_info(self, hass: HomeAssistant) -> ChromecastInfo:
|
||||
"""Return a new ChromecastInfo object with missing attributes filled in.
|
||||
|
||||
Uses blocking HTTP / HTTPS.
|
||||
"""
|
||||
cast_info = self.cast_info
|
||||
if self.cast_info.cast_type is None or self.cast_info.manufacturer is None:
|
||||
unknown_models = config_entry.runtime_data.unknown_models
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=hass-use-runtime-data
|
||||
unknown_models = hass.data[DOMAIN]["unknown_models"]
|
||||
if self.cast_info.model_name not in unknown_models:
|
||||
# Manufacturer and cast type is not available in mDNS data,
|
||||
# get it over HTTP
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Home Assistant Cast integration for Cast."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import auth, core
|
||||
from homeassistant import auth, config_entries, core
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
|
||||
@@ -13,9 +11,6 @@ from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW, HomeAssistantControllerData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CastConfigEntry
|
||||
|
||||
SERVICE_SHOW_VIEW = "show_lovelace_view"
|
||||
ATTR_VIEW_PATH = "view_path"
|
||||
ATTR_URL_PATH = "dashboard_path"
|
||||
@@ -26,7 +21,9 @@ NO_URL_AVAILABLE_ERROR = (
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
|
||||
async def async_setup_ha_cast(
|
||||
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
"""Set up Home Assistant Cast."""
|
||||
user_id: str | None = entry.data.get("user_id")
|
||||
user: auth.models.User | None = None
|
||||
@@ -90,7 +87,9 @@ async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry)
|
||||
)
|
||||
|
||||
|
||||
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
|
||||
async def async_remove_user(
|
||||
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
"""Remove Home Assistant Cast user."""
|
||||
user_id: str | None = entry.data.get("user_id")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Provide functionality to interact with Cast devices on the network."""
|
||||
# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
@@ -41,6 +42,7 @@ from homeassistant.components.media_player import (
|
||||
MediaType,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CAST_APP_ID_HOMEASSISTANT_LOVELACE,
|
||||
CONF_UUID,
|
||||
@@ -56,6 +58,8 @@ from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.logging import async_create_catching_coro
|
||||
|
||||
from .const import (
|
||||
ADDED_CAST_DEVICES_KEY,
|
||||
CAST_MULTIZONE_MANAGER_KEY,
|
||||
CONF_IGNORE_CEC,
|
||||
DOMAIN,
|
||||
SIGNAL_CAST_DISCOVERED,
|
||||
@@ -74,7 +78,7 @@ from .helpers import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CastConfigEntry, CastProtocol
|
||||
from . import CastProtocol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -106,9 +110,7 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
|
||||
|
||||
|
||||
@callback
|
||||
def _async_create_cast_device(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
|
||||
):
|
||||
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
|
||||
"""Create a CastDevice entity or dynamic group from the chromecast object.
|
||||
|
||||
Returns None if the cast device has already been added.
|
||||
@@ -119,7 +121,7 @@ def _async_create_cast_device(
|
||||
return None
|
||||
|
||||
# Found a cast with UUID
|
||||
added_casts = config_entry.runtime_data.added_cast_devices
|
||||
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
|
||||
if info.uuid in added_casts:
|
||||
# Already added this one, the entity will take care of moved hosts
|
||||
# itself
|
||||
@@ -129,19 +131,21 @@ def _async_create_cast_device(
|
||||
|
||||
if info.is_dynamic_group:
|
||||
# This is a dynamic group, do not add it but connect to the service.
|
||||
group = DynamicCastGroup(hass, config_entry, info)
|
||||
group = DynamicCastGroup(hass, info)
|
||||
group.async_setup()
|
||||
return None
|
||||
|
||||
return CastMediaPlayerEntity(hass, config_entry, info)
|
||||
return CastMediaPlayerEntity(hass, info)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CastConfigEntry,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Cast from a config entry."""
|
||||
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
|
||||
|
||||
# Import CEC IGNORE attributes
|
||||
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
|
||||
|
||||
@@ -156,7 +160,7 @@ async def async_setup_entry(
|
||||
# UUID not matching, ignore.
|
||||
return
|
||||
|
||||
cast_device = _async_create_cast_device(hass, config_entry, discover)
|
||||
cast_device = _async_create_cast_device(hass, discover)
|
||||
if cast_device is not None:
|
||||
async_add_entities([cast_device])
|
||||
|
||||
@@ -175,19 +179,13 @@ class CastDevice:
|
||||
|
||||
_mz_only: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: CastConfigEntry,
|
||||
cast_info: ChromecastInfo,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
|
||||
"""Initialize the cast device."""
|
||||
|
||||
self.hass: HomeAssistant = hass
|
||||
self._config_entry = config_entry
|
||||
self._cast_info = cast_info
|
||||
self._chromecast: pychromecast.Chromecast | None = None
|
||||
self.mz_mgr: MultizoneManager | None = None
|
||||
self.mz_mgr = None
|
||||
self._status_listener: CastStatusListener | None = None
|
||||
self._add_remove_handler: Callable[[], None] | None = None
|
||||
self._del_remove_handler: Callable[[], None] | None = None
|
||||
@@ -216,9 +214,7 @@ class CastDevice:
|
||||
if self._cast_info.uuid is not None:
|
||||
# Remove the entity from the added casts so that it can dynamically
|
||||
# be re-added again.
|
||||
self._config_entry.runtime_data.added_cast_devices.remove(
|
||||
self._cast_info.uuid
|
||||
)
|
||||
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
|
||||
if self._add_remove_handler:
|
||||
self._add_remove_handler()
|
||||
self._add_remove_handler = None
|
||||
@@ -241,10 +237,10 @@ class CastDevice:
|
||||
)
|
||||
self._chromecast = chromecast
|
||||
|
||||
runtime_data = self._config_entry.runtime_data
|
||||
if runtime_data.multizone_manager is None:
|
||||
runtime_data.multizone_manager = MultizoneManager()
|
||||
self.mz_mgr = runtime_data.multizone_manager
|
||||
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
|
||||
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
|
||||
|
||||
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
|
||||
|
||||
self._status_listener = CastStatusListener(
|
||||
self, chromecast, self.mz_mgr, self._mz_only
|
||||
@@ -304,15 +300,10 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_mz_only = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: CastConfigEntry,
|
||||
cast_info: ChromecastInfo,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
|
||||
"""Initialize the cast device."""
|
||||
|
||||
CastDevice.__init__(self, hass, config_entry, cast_info)
|
||||
CastDevice.__init__(self, hass, cast_info)
|
||||
|
||||
self.cast_status = None
|
||||
self.media_status = None
|
||||
@@ -601,7 +592,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
"""Generate root node."""
|
||||
children = []
|
||||
# Add media browsers
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
children.extend(
|
||||
await platform.async_get_media_browser_root_object(
|
||||
self.hass, self._chromecast.cast_type
|
||||
@@ -660,7 +651,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
platform: CastProtocol
|
||||
assert media_content_type is not None
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
browse_media = await platform.async_browse_media(
|
||||
self.hass,
|
||||
media_content_type,
|
||||
@@ -722,7 +713,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return
|
||||
|
||||
# Try the cast platforms
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
result = await platform.async_play_media(
|
||||
self.hass, self.entity_id, chromecast, media_type, media_id
|
||||
)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""The CentriConnect/MyPropane API integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: CentriConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up CentriConnect/MyPropane API from a config entry."""
|
||||
coordinator = CentriConnectCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: CentriConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Unload CentriConnect/MyPropane API integration platforms and coordinator."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Config flow for the CentriConnect/MyPropane API integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiocentriconnect import CentriConnect
|
||||
from aiocentriconnect.exceptions import (
|
||||
CentriConnectConnectionError,
|
||||
CentriConnectDecodeError,
|
||||
CentriConnectEmptyResponseError,
|
||||
CentriConnectNotFoundError,
|
||||
CentriConnectTooManyRequestsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CENTRICONNECT_DEVICE_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_DEVICE_ID): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
# Validate the user-supplied data can be used to set up a connection.
|
||||
hub = CentriConnect(
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_DEVICE_ID],
|
||||
data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
tank_data = await hub.async_get_tank_data()
|
||||
|
||||
# Return info to store in the config entry.
|
||||
return {
|
||||
"title": tank_data.device_name,
|
||||
CENTRICONNECT_DEVICE_ID: tank_data.device_id,
|
||||
}
|
||||
|
||||
|
||||
class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for CentriConnect/MyPropane API."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CentriConnectConnectionError, CentriConnectTooManyRequestsError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except CentriConnectNotFoundError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CentriConnectEmptyResponseError, CentriConnectDecodeError:
|
||||
errors["base"] = "unknown"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=user_input, reload_on_update=True
|
||||
)
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Constants for the CentriConnect/MyPropane API integration."""
|
||||
|
||||
DOMAIN = "centriconnect"
|
||||
|
||||
CENTRICONNECT_DEVICE_ID = "device_id"
|
||||
@@ -1,88 +0,0 @@
|
||||
"""Coordinator for CentriConnect/MyPropane API integration.
|
||||
|
||||
Responsible for polling the device API endpoint and normalizing data for entities.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiocentriconnect import CentriConnect, Tank
|
||||
from aiocentriconnect.exceptions import CentriConnectConnectionError, CentriConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COORDINATOR_NAME = f"{DOMAIN} Coordinator"
|
||||
# Maximum update frequency is every 6 hours. The API will return 429 Too Many Requests if polled frequently.
|
||||
# The device updates its data every 8-12 hours, so there's no need to poll more frequently.
|
||||
UPDATE_INTERVAL = timedelta(hours=6)
|
||||
|
||||
type CentriConnectConfigEntry = ConfigEntry[CentriConnectCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CentriConnectDeviceInfo:
|
||||
"""Data about the CentriConnect device."""
|
||||
|
||||
device_id: str
|
||||
device_name: str
|
||||
hardware_version: str
|
||||
lte_version: str
|
||||
tank_size: int
|
||||
tank_size_unit: str
|
||||
|
||||
|
||||
class CentriConnectCoordinator(DataUpdateCoordinator[Tank]):
|
||||
"""Data update coordinator for CentriConnect/MyPropane devices."""
|
||||
|
||||
config_entry: CentriConnectConfigEntry
|
||||
device_info: CentriConnectDeviceInfo
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: CentriConnectConfigEntry) -> None:
|
||||
"""Initialize the CentriConnect data update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=COORDINATOR_NAME,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
self.api_client = CentriConnect(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_DEVICE_ID],
|
||||
entry.data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
tank_data = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed("Could not fetch device info") from err
|
||||
self.device_info = CentriConnectDeviceInfo(
|
||||
device_id=tank_data.device_id,
|
||||
device_name=tank_data.device_name,
|
||||
hardware_version=tank_data.hardware_version,
|
||||
lte_version=tank_data.lte_version,
|
||||
tank_size=tank_data.tank_size,
|
||||
tank_size_unit=tank_data.tank_size_unit,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Tank:
|
||||
"""Fetch device state."""
|
||||
try:
|
||||
state = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with device: {err}") from err
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed(f"Unexpected response: {err}") from err
|
||||
return state
|
||||
@@ -1,37 +0,0 @@
|
||||
"""Defines a base CentriConnect entity."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CentriConnectCoordinator
|
||||
|
||||
|
||||
class CentriConnectBaseEntity(CoordinatorEntity[CentriConnectCoordinator]):
|
||||
"""Defines a base CentriConnect entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CentriConnectCoordinator,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the CentriConnect entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
name=coordinator.device_info.device_name,
|
||||
serial_number=coordinator.device_info.device_id,
|
||||
hw_version=coordinator.device_info.hardware_version,
|
||||
sw_version=coordinator.device_info.lte_version,
|
||||
manufacturer="CentriConnect",
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
self.entity_description = description
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alert_status": {
|
||||
"default": "mdi:alert-circle-outline",
|
||||
"state": {
|
||||
"critical_level": "mdi:alert-circle",
|
||||
"low_level": "mdi:alert-circle-outline",
|
||||
"no_alert": "mdi:check-circle-outline"
|
||||
}
|
||||
},
|
||||
"altitude": {
|
||||
"default": "mdi:altimeter"
|
||||
},
|
||||
"battery_voltage": {
|
||||
"default": "mdi:car-battery"
|
||||
},
|
||||
"device_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"last_post_time": {
|
||||
"default": "mdi:clock-end"
|
||||
},
|
||||
"latitude": {
|
||||
"default": "mdi:latitude"
|
||||
},
|
||||
"longitude": {
|
||||
"default": "mdi:longitude"
|
||||
},
|
||||
"lte_signal_level": {
|
||||
"default": "mdi:signal",
|
||||
"range": {
|
||||
"0": "mdi:signal-cellular-outline",
|
||||
"25": "mdi:signal-cellular-1",
|
||||
"50": "mdi:signal-cellular-2",
|
||||
"75": "mdi:signal-cellular-3"
|
||||
}
|
||||
},
|
||||
"lte_signal_strength": {
|
||||
"default": "mdi:signal-variant"
|
||||
},
|
||||
"next_post_time": {
|
||||
"default": "mdi:clock-start"
|
||||
},
|
||||
"solar_level": {
|
||||
"default": "mdi:sun-wireless"
|
||||
},
|
||||
"solar_voltage": {
|
||||
"default": "mdi:solar-power"
|
||||
},
|
||||
"tank_level": {
|
||||
"default": "mdi:gauge",
|
||||
"range": {
|
||||
"0": "mdi:gauge-empty",
|
||||
"25": "mdi:gauge-low",
|
||||
"50": "mdi:gauge",
|
||||
"75": "mdi:gauge-full"
|
||||
}
|
||||
},
|
||||
"tank_remaining_volume": {
|
||||
"default": "mdi:storage-tank-outline"
|
||||
},
|
||||
"tank_size": {
|
||||
"default": "mdi:storage-tank"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "centriconnect",
|
||||
"name": "CentriConnect/MyPropane",
|
||||
"codeowners": ["@gresrun"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/centriconnect",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocentriconnect==0.2.3"]
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not provide an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration is not a hub and only represents a single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No user-actionable repair scenarios identified for this integration.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Devices removed from account stop appearing in API responses and become unavailable.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -1,242 +0,0 @@
|
||||
"""Sensor platform for CentriConnect/MyPropane API integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfLength,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
|
||||
from .entity import CentriConnectBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
_ALERT_STATUS_VALUES = {
|
||||
"No Alert": "no_alert",
|
||||
"Low Level": "low_level",
|
||||
"Critical Level": "critical_level",
|
||||
}
|
||||
|
||||
|
||||
class CentriConnectSensorType(StrEnum):
|
||||
"""Enumerates CentriConnect sensor types exposed by the device."""
|
||||
|
||||
ALERT_STATUS = "alert_status"
|
||||
ALTITUDE = "altitude"
|
||||
BATTERY_LEVEL = "battery_level"
|
||||
BATTERY_VOLTAGE = "battery_voltage"
|
||||
DEVICE_TEMPERATURE = "device_temperature"
|
||||
LAST_POST_TIME = "last_post_time"
|
||||
LATITUDE = "latitude"
|
||||
LONGITUDE = "longitude"
|
||||
LTE_SIGNAL_LEVEL = "lte_signal_level"
|
||||
LTE_SIGNAL_STRENGTH = "lte_signal_strength"
|
||||
NEXT_POST_TIME = "next_post_time"
|
||||
SOLAR_LEVEL = "solar_level"
|
||||
SOLAR_VOLTAGE = "solar_voltage"
|
||||
TANK_LEVEL = "tank_level"
|
||||
TANK_REMAINING_VOLUME = "tank_remaining_volume"
|
||||
TANK_SIZE = "tank_size"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CentriConnectSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of a CentriConnect sensor entity."""
|
||||
|
||||
key: CentriConnectSensorType
|
||||
value_fn: Callable[[CentriConnectCoordinator], StateType | datetime | None]
|
||||
|
||||
|
||||
ENTITIES: tuple[CentriConnectSensorEntityDescription, ...] = (
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.ALERT_STATUS,
|
||||
translation_key=CentriConnectSensorType.ALERT_STATUS,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(_ALERT_STATUS_VALUES.values()),
|
||||
value_fn=lambda coord: _ALERT_STATUS_VALUES.get(coord.data.alert_status),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.ALTITUDE,
|
||||
translation_key=CentriConnectSensorType.ALTITUDE,
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: coord.data.altitude,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.BATTERY_LEVEL,
|
||||
translation_key=CentriConnectSensorType.BATTERY_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coord: coord.data.battery_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.BATTERY_VOLTAGE,
|
||||
translation_key=CentriConnectSensorType.BATTERY_VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.battery_voltage,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.DEVICE_TEMPERATURE,
|
||||
translation_key=CentriConnectSensorType.DEVICE_TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda coord: coord.data.device_temperature,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
|
||||
translation_key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.lte_signal_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
|
||||
translation_key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda coord: coord.data.lte_signal_strength,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.SOLAR_LEVEL,
|
||||
translation_key=CentriConnectSensorType.SOLAR_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.solar_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.SOLAR_VOLTAGE,
|
||||
translation_key=CentriConnectSensorType.SOLAR_VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.solar_voltage,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_LEVEL,
|
||||
translation_key=CentriConnectSensorType.TANK_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda coord: coord.data.tank_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.data.tank_remaining_volume
|
||||
if coord.device_info.tank_size_unit == "Gallons"
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.data.tank_remaining_volume
|
||||
if coord.device_info.tank_size_unit == "Liters"
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_SIZE,
|
||||
translation_key=CentriConnectSensorType.TANK_SIZE,
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.device_info.tank_size
|
||||
if (coord.device_info.tank_size_unit == "Gallons")
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_SIZE,
|
||||
translation_key=CentriConnectSensorType.TANK_SIZE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.device_info.tank_size
|
||||
if (coord.device_info.tank_size_unit == "Liters")
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CentriConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up CentriConnect sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
CentriConnectSensor(entry.runtime_data, description)
|
||||
for description in ENTITIES
|
||||
if description.value_fn(entry.runtime_data) is not None
|
||||
)
|
||||
|
||||
|
||||
class CentriConnectSensor(CentriConnectBaseEntity, SensorEntity):
|
||||
"""Representation of a CentriConnect sensor entity."""
|
||||
|
||||
entity_description: CentriConnectSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device_id": "Device ID",
|
||||
"password": "Device Authentication Code",
|
||||
"username": "User ID"
|
||||
},
|
||||
"data_description": {
|
||||
"device_id": "Your CentriConnect/MyPropane device ID",
|
||||
"password": "Your CentriConnect/MyPropane device authentication code",
|
||||
"username": "Your CentriConnect/MyPropane user ID"
|
||||
},
|
||||
"description": "Enter your CentriConnect/MyPropane device credentials."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alert_status": {
|
||||
"name": "Alert status",
|
||||
"state": {
|
||||
"critical_level": "Critical level",
|
||||
"low_level": "Low level",
|
||||
"no_alert": "No alert"
|
||||
}
|
||||
},
|
||||
"altitude": {
|
||||
"name": "Altitude"
|
||||
},
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"device_temperature": {
|
||||
"name": "Device temperature"
|
||||
},
|
||||
"lte_signal_level": {
|
||||
"name": "LTE signal level"
|
||||
},
|
||||
"lte_signal_strength": {
|
||||
"name": "LTE signal strength"
|
||||
},
|
||||
"solar_level": {
|
||||
"name": "Solar level"
|
||||
},
|
||||
"solar_voltage": {
|
||||
"name": "Solar voltage"
|
||||
},
|
||||
"tank_level": {
|
||||
"name": "Tank level"
|
||||
},
|
||||
"tank_remaining_volume": {
|
||||
"name": "Tank remaining volume"
|
||||
},
|
||||
"tank_size": {
|
||||
"name": "Tank size"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Integration for Cielo Home."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool:
|
||||
"""Set up Cielo Home from a config entry."""
|
||||
coordinator = CieloDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CieloHomeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.async_shutdown()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,311 +0,0 @@
|
||||
"""Support for Cielo home thermostats and Smart AC Controllers."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate, ParamSpec, TypeVar
|
||||
|
||||
from cieloconnectapi.exceptions import AuthenticationError
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import CIELO_ERRORS, LOGGER, TIMEOUT
|
||||
from .coordinator import CieloDataUpdateCoordinator, CieloHomeConfigEntry
|
||||
from .entity import CieloDeviceEntity
|
||||
|
||||
_T = TypeVar("_T", bound="CieloDeviceEntity")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CIELO_TO_HA_HVAC: dict[str, HVACMode] = {
|
||||
"cool": HVACMode.COOL,
|
||||
"heat": HVACMode.HEAT,
|
||||
"fan": HVACMode.FAN_ONLY,
|
||||
"dry": HVACMode.DRY,
|
||||
"auto": HVACMode.AUTO,
|
||||
"heat_cool": HVACMode.HEAT_COOL,
|
||||
"off": HVACMode.OFF,
|
||||
}
|
||||
HA_TO_CIELO_HVAC: dict[HVACMode, str] = {v: k for k, v in CIELO_TO_HA_HVAC.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CieloHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Cielo climate platform."""
|
||||
coordinator = entry.runtime_data
|
||||
devices = coordinator.data.parsed
|
||||
async_add_entities([CieloClimate(coordinator, dev_id) for dev_id in devices])
|
||||
|
||||
|
||||
def async_handle_api_call(
|
||||
function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]:
|
||||
"""Decorate api calls to handle exceptions and update state."""
|
||||
|
||||
async def wrap_api_call(*args: Any, **kwargs: Any) -> None:
|
||||
"""Wrap services for api calls."""
|
||||
entity: _T = args[0]
|
||||
res: Any = None
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(TIMEOUT):
|
||||
res = await function(*args, **kwargs)
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
except CIELO_ERRORS as err:
|
||||
if isinstance(err, TimeoutError):
|
||||
raise HomeAssistantError("API call timed out") from err
|
||||
raise HomeAssistantError("Unable to perform API call") from err
|
||||
|
||||
LOGGER.debug(
|
||||
"API call result for entity %s: type=%s keys=%s",
|
||||
entity.entity_id,
|
||||
type(res),
|
||||
list(res.keys()) if isinstance(res, dict) else None,
|
||||
)
|
||||
|
||||
if not isinstance(res, dict):
|
||||
LOGGER.error(
|
||||
"API function did not return a dictionary for entity %s, got %s",
|
||||
entity.entity_id,
|
||||
type(res),
|
||||
)
|
||||
raise HomeAssistantError("Invalid API response format")
|
||||
|
||||
data: dict[str, Any] | None = res.get("data")
|
||||
|
||||
if not data:
|
||||
raise HomeAssistantError("API response contained no data payload")
|
||||
|
||||
await entity.coordinator.async_apply_action_result(entity.device_id, data)
|
||||
|
||||
return wrap_api_call
|
||||
|
||||
|
||||
class CieloClimate(CieloDeviceEntity, ClimateEntity):
|
||||
"""Representation of a Cielo Smart AC Controller."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = "climate_device"
|
||||
|
||||
def __init__(self, coordinator: CieloDataUpdateCoordinator, device_id: str) -> None:
|
||||
"""Initialize the climate device."""
|
||||
super().__init__(coordinator, device_id)
|
||||
self._attr_unique_id = device_id
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
"""Return the unit of temperature in Home Assistant format.
|
||||
|
||||
It can change over time based on the device settings, so we fetch it dynamically from the client.
|
||||
"""
|
||||
unit = self.client.temperature_unit()
|
||||
|
||||
if not unit:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
normalized = unit.strip().lower()
|
||||
|
||||
if normalized in {"c", "°c", "celsius"}:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
if normalized in {"f", "°f", "fahrenheit"}:
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
|
||||
return UnitOfTemperature.CELSIUS
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
"""Return dynamic feature flags based on the current mode."""
|
||||
flags = ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
|
||||
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
flags |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
elif self.client.mode_supports_temperature():
|
||||
flags |= ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
caps = self.client.mode_caps()
|
||||
|
||||
if caps.get("fan_levels"):
|
||||
flags |= ClimateEntityFeature.FAN_MODE
|
||||
|
||||
if caps.get("swing"):
|
||||
flags |= ClimateEntityFeature.SWING_MODE
|
||||
|
||||
if self.device_data and self.device_data.preset_modes:
|
||||
flags |= ClimateEntityFeature.PRESET_MODE
|
||||
|
||||
return flags
|
||||
|
||||
@property
|
||||
def current_humidity(self) -> int | None:
|
||||
"""Return the current humidity, if available."""
|
||||
if self.device_data:
|
||||
return self.device_data.humidity
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_low(self) -> float | None:
|
||||
"""Return the low target temperature for HEAT_COOL mode."""
|
||||
return self.client.target_temperature_low(self.temperature_unit)
|
||||
|
||||
@property
|
||||
def target_temperature_high(self) -> float | None:
|
||||
"""Return the high target temperature for HEAT_COOL mode."""
|
||||
return self.client.target_temperature_high(self.temperature_unit)
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
mode = self.client.hvac_mode()
|
||||
return CIELO_TO_HA_HVAC.get(mode, mode)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available HVAC modes."""
|
||||
modes = self.client.hvac_modes() or []
|
||||
return [CIELO_TO_HA_HVAC.get(m, m) for m in modes]
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current indoor temperature."""
|
||||
return self.client.current_temperature()
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target temperature."""
|
||||
return self.client.target_temperature()
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum possible target temperature."""
|
||||
return self.client.min_temp()
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum possible target temperature."""
|
||||
return self.client.max_temp()
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float | None:
|
||||
"""Return the precision of the thermostat."""
|
||||
return self.client.target_temperature_step(self.temperature_unit)
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> str | None:
|
||||
"""Return the current fan mode."""
|
||||
return self.client.fan_mode()
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str] | None:
|
||||
"""Return the list of available fan modes.
|
||||
|
||||
Fan modes are normalized in the backend to snake_case values that
|
||||
match Home Assistant expectations (e.g. "low", "medium", "high", "auto").
|
||||
This allows HA to translate and display icons correctly using the
|
||||
integration strings definitions.
|
||||
"""
|
||||
return self.client.fan_modes()
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str] | None:
|
||||
"""Return the list of available swing modes.
|
||||
|
||||
Swing modes are normalized in the backend to snake_case values
|
||||
compatible with Home Assistant (e.g. "auto", "swing").
|
||||
These values align with the integration translations so HA can display
|
||||
proper labels and icons.
|
||||
"""
|
||||
return self.client.swing_modes()
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self.client.preset_mode()
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the list of available preset modes.
|
||||
|
||||
Preset modes are normalized in the backend to snake_case values that
|
||||
match Home Assistant expectations (e.g. "home", "away", "sleep", "pets").
|
||||
This allows HA to translate and display icons correctly using the
|
||||
integration strings definitions.
|
||||
"""
|
||||
return self.client.preset_modes()
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str | None:
|
||||
"""Return the current swing mode."""
|
||||
return self.device_data.swing_mode if self.device_data else None
|
||||
|
||||
@property
|
||||
def precision(self) -> float:
|
||||
"""Return the precision of the thermostat."""
|
||||
return self.client.precision(self.temperature_unit)
|
||||
|
||||
@async_handle_api_call
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if self.hvac_mode == HVACMode.HEAT_COOL:
|
||||
return await self.client.async_set_temperature(
|
||||
self.temperature_unit,
|
||||
**{
|
||||
ATTR_TARGET_TEMP_LOW: kwargs.get(ATTR_TARGET_TEMP_LOW),
|
||||
ATTR_TARGET_TEMP_HIGH: kwargs.get(ATTR_TARGET_TEMP_HIGH),
|
||||
},
|
||||
)
|
||||
return await self.client.async_set_temperature(
|
||||
self.temperature_unit,
|
||||
**{ATTR_TEMPERATURE: kwargs.get(ATTR_TEMPERATURE)},
|
||||
)
|
||||
|
||||
@async_handle_api_call
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
"""Set new fan mode."""
|
||||
return await self.client.async_set_fan_mode(fan_mode)
|
||||
|
||||
@async_handle_api_call
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
return await self.client.async_set_preset_mode(preset_mode)
|
||||
|
||||
@async_handle_api_call
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new HVAC mode."""
|
||||
cielo_mode = HA_TO_CIELO_HVAC.get(hvac_mode)
|
||||
return await self.client.async_set_hvac_mode(cielo_mode)
|
||||
|
||||
@async_handle_api_call
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set new swing mode."""
|
||||
return await self.client.async_set_swing_mode(swing_mode)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the climate device on."""
|
||||
modes = self.hvac_modes or []
|
||||
|
||||
# Select the first supported non-off mode when turning on
|
||||
for mode in modes:
|
||||
if mode != HVACMode.OFF:
|
||||
await self.async_set_hvac_mode(mode)
|
||||
return
|
||||
|
||||
raise HomeAssistantError("No non-off HVAC modes available to turn on device")
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the climate device off."""
|
||||
await self.async_set_hvac_mode(HVACMode.OFF)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Config Flow for Cielo integration."""
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from aiohttp import ClientError
|
||||
from cieloconnectapi import CieloClient
|
||||
from cieloconnectapi.exceptions import AuthenticationError, CieloError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN, LOGGER, TIMEOUT
|
||||
|
||||
DATA_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CieloConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Cielo integration."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def _async_validate_api_key(
|
||||
self, api_key: str
|
||||
) -> tuple[str | None, dict[str, str]]:
|
||||
"""Validate the API key, initialize the client, and return errors or token."""
|
||||
client = CieloClient(
|
||||
api_key=api_key,
|
||||
timeout=TIMEOUT,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
try:
|
||||
token = await client.get_or_refresh_token()
|
||||
|
||||
devices = await client.get_devices_data()
|
||||
if not devices.parsed:
|
||||
return None, {"base": "no_devices"}
|
||||
|
||||
except AuthenticationError:
|
||||
return None, {"base": "invalid_auth"}
|
||||
except ConnectionError, TimeoutError, ClientError, CieloError:
|
||||
return None, {"base": "cannot_connect"}
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception during config flow validation")
|
||||
return None, {"base": "unknown"}
|
||||
|
||||
return client.user_id, {CONF_TOKEN: token}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input:
|
||||
api_key = user_input[CONF_API_KEY].strip()
|
||||
|
||||
user_id, validation_result = await self._async_validate_api_key(api_key)
|
||||
|
||||
if "base" in validation_result:
|
||||
errors = validation_result
|
||||
else:
|
||||
token: str = validation_result[CONF_TOKEN]
|
||||
|
||||
user_input[CONF_API_KEY] = api_key
|
||||
user_input[CONF_TOKEN] = token
|
||||
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_NAME,
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
# Show the user form
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"url": "https://www.home-assistant.io/integrations/cielo_home"
|
||||
},
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Constants for the Cielo Home integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import ClientError
|
||||
from cieloconnectapi.exceptions import CieloError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN: Final = "cielo_home"
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
Platform.CLIMATE,
|
||||
]
|
||||
DEFAULT_NAME: Final = "Cielo Home"
|
||||
DEFAULT_SCAN_INTERVAL: Final[int] = 2 * 60
|
||||
TIMEOUT: Final[int] = 20
|
||||
LOGGER: Final = logging.getLogger(__package__)
|
||||
|
||||
CIELO_ERRORS: Final[tuple] = (
|
||||
ClientError,
|
||||
TimeoutError,
|
||||
CieloError,
|
||||
)
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Coordinator for Cielo integration."""
|
||||
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from typing import Any, Final
|
||||
|
||||
from aiohttp import ClientError
|
||||
from cieloconnectapi import CieloClient
|
||||
from cieloconnectapi.exceptions import AuthenticationError, CieloError
|
||||
from cieloconnectapi.model import CieloDevice
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
|
||||
|
||||
REQUEST_REFRESH_DELAY: Final[int] = 2 * 60
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CieloData:
|
||||
"""Data structure for the coordinator."""
|
||||
|
||||
raw: dict[str, Any]
|
||||
parsed: dict[str, CieloDevice]
|
||||
|
||||
|
||||
class CieloDataUpdateCoordinator(DataUpdateCoordinator[CieloData]):
|
||||
"""Cielo Data Update Coordinator."""
|
||||
|
||||
config_entry: CieloHomeConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: CieloHomeConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.client = CieloClient(
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
timeout=TIMEOUT,
|
||||
token=entry.data[CONF_TOKEN],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
name=DOMAIN,
|
||||
config_entry=entry,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
# The debouncer prevents multiple rapid refresh requests from triggering repeated full data fetches from the backend.
|
||||
request_refresh_debouncer=Debouncer(
|
||||
hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
|
||||
),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> CieloData:
|
||||
"""Fetch data from the API."""
|
||||
try:
|
||||
data = await self.client.get_devices_data()
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
except (TimeoutError, ConnectionError, CieloError, ClientError) as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
return CieloData(raw=data.raw, parsed=data.parsed)
|
||||
|
||||
async def async_apply_action_result(
|
||||
self, device_id: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Apply an optimistic update from an API action response.
|
||||
|
||||
This updates the affected device locally in the coordinator state so the
|
||||
UI reflects the change immediately without requiring a full backend refresh.
|
||||
|
||||
Performing a coordinator refresh after every action would fetch all devices
|
||||
for the account, even when only a single device was updated. This is not
|
||||
optimal from an API usage/cost perspective.
|
||||
|
||||
Instead, the coordinator applies the action result locally for the affected
|
||||
device and schedules a later refresh to reconcile with the backend state.
|
||||
"""
|
||||
if not self.data or not self.data.parsed or device_id not in self.data.parsed:
|
||||
await self.async_request_refresh()
|
||||
return
|
||||
|
||||
new_parsed = dict(self.data.parsed)
|
||||
dev = copy(new_parsed[device_id])
|
||||
|
||||
try:
|
||||
dev.apply_update(data)
|
||||
except KeyError, ValueError, TypeError:
|
||||
await self.async_request_refresh()
|
||||
return
|
||||
|
||||
new_parsed[device_id] = dev
|
||||
self.async_set_updated_data(CieloData(raw=self.data.raw, parsed=new_parsed))
|
||||
|
||||
# Request a debounced refresh to reconcile with the backend state.
|
||||
await self.async_request_refresh()
|
||||
|
||||
|
||||
# Define the ConfigEntry type here to avoid circular imports
|
||||
type CieloHomeConfigEntry = ConfigEntry[CieloDataUpdateCoordinator]
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Base entity for Cielo integration."""
|
||||
|
||||
from cieloconnectapi.device import CieloDeviceAPI
|
||||
from cieloconnectapi.model import CieloDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CieloDataUpdateCoordinator
|
||||
|
||||
|
||||
class CieloBaseEntity(CoordinatorEntity[CieloDataUpdateCoordinator]):
|
||||
"""Representation of a Cielo base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CieloDataUpdateCoordinator,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
"""Initialize the Cielo base entity."""
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self.client = CieloDeviceAPI(
|
||||
coordinator.client, coordinator.data.parsed[device_id]
|
||||
)
|
||||
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if (dev := self.device_data) is not None:
|
||||
self.client.device_data = dev
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def device_data(self) -> CieloDevice | None:
|
||||
"""Return the device data from the coordinator."""
|
||||
return self.coordinator.data.parsed.get(self._device_id)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the device is available and online."""
|
||||
if not (super().available and self._device_id in self.coordinator.data.parsed):
|
||||
return False
|
||||
|
||||
dev = self.device_data
|
||||
return bool(dev and dev.device_status)
|
||||
|
||||
|
||||
class CieloDeviceEntity(CieloBaseEntity):
|
||||
"""Representation of a Cielo Device."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CieloDataUpdateCoordinator,
|
||||
device_id: str,
|
||||
) -> None:
|
||||
"""Initialize the device entity."""
|
||||
super().__init__(coordinator, device_id)
|
||||
self.device_id = device_id
|
||||
|
||||
device = coordinator.data.parsed[device_id]
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(device.mac_address))},
|
||||
manufacturer="Cielo",
|
||||
configuration_url="https://home.cielowigle.com/",
|
||||
suggested_area=device.name,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "cielo_home",
|
||||
"name": "Cielo Home",
|
||||
"codeowners": ["@ihsan-cielo", "@mudasar-cielo"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/cielo_home",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["cieloconnectapi"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["cielo-connect-api==1.0.6"]
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "Invalid or expired API key; generate a new one",
|
||||
"no_devices": "No devices found; make sure devices are set up in the Cielo Home app",
|
||||
"no_user_id": "No valid user information found for the API key",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key from your Cielo Home account"
|
||||
},
|
||||
"description": "Sign in with your Cielo Home API key. Follow the [documentation]({url}) to learn how to get your API key.",
|
||||
"title": "Connect to Cielo Home"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"climate": {
|
||||
"climate_device": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"quiet": "Quiet",
|
||||
"super_high": "Super high",
|
||||
"ultra_high": "Ultra high"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"adjust": "Adjust",
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"auto_stop": "Auto Stop",
|
||||
"pos1": "Position 1",
|
||||
"pos10": "Position 10",
|
||||
"pos11": "Position 11",
|
||||
"pos12": "Position 12",
|
||||
"pos13": "Position 13",
|
||||
"pos14": "Position 14",
|
||||
"pos15": "Position 15",
|
||||
"pos2": "Position 2",
|
||||
"pos3": "Position 3",
|
||||
"pos4": "Position 4",
|
||||
"pos5": "Position 5",
|
||||
"pos6": "Position 6",
|
||||
"pos7": "Position 7",
|
||||
"pos8": "Position 8",
|
||||
"pos9": "Position 9",
|
||||
"swing": "Swing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import voluptuous as vol
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
repairs_flow_manager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DATA_CLOUD, DOMAIN
|
||||
@@ -50,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
wait_task: asyncio.Task | None = None
|
||||
_data: SubscriptionInfo | None = None
|
||||
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_change_plan()
|
||||
|
||||
async def async_step_confirm_change_plan(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> RepairsFlowResult:
|
||||
) -> FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_change_plan()
|
||||
@@ -66,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
step_id="confirm_change_plan", data_schema=vol.Schema({})
|
||||
)
|
||||
|
||||
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_change_plan(self, _: None = None) -> FlowResult:
|
||||
"""Wait for the user to authorize the app installation."""
|
||||
|
||||
cloud = self.hass.data[DATA_CLOUD]
|
||||
@@ -107,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
|
||||
return self.async_external_step_done(next_step_id="complete")
|
||||
|
||||
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_complete(self, _: None = None) -> FlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_timeout(self, _: None = None) -> FlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_abort(reason="operation_took_too_long")
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
"""Platform for Control4 Covers (blinds and shades)."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyControl4.blind import C4Blind
|
||||
from pyControl4.error_handling import C4Exception
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from . import Control4ConfigEntry, get_items_of_category
|
||||
from .const import CONTROL4_ENTITY_TYPE
|
||||
from .director_utils import update_variables_for_config_entry
|
||||
from .entity import Control4Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONTROL4_CATEGORY = "blinds_shades"
|
||||
|
||||
CONTROL4_LEVEL = "Level"
|
||||
CONTROL4_FULLY_CLOSED = "Fully Closed"
|
||||
CONTROL4_FULLY_OPEN = "Fully Open"
|
||||
CONTROL4_OPENING = "Opening"
|
||||
CONTROL4_CLOSING = "Closing"
|
||||
|
||||
VARIABLES_OF_INTEREST = {
|
||||
CONTROL4_LEVEL,
|
||||
CONTROL4_FULLY_CLOSED,
|
||||
CONTROL4_FULLY_OPEN,
|
||||
CONTROL4_OPENING,
|
||||
CONTROL4_CLOSING,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: Control4ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Control4 covers from a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
async def async_update_data() -> dict[int, dict[str, Any]]:
|
||||
"""Fetch data from Control4 director for blinds."""
|
||||
try:
|
||||
return await update_variables_for_config_entry(
|
||||
hass, entry, VARIABLES_OF_INTEREST
|
||||
)
|
||||
except C4Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="cover",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
|
||||
entity_list = []
|
||||
for item in items_of_category:
|
||||
try:
|
||||
if item["type"] != CONTROL4_ENTITY_TYPE:
|
||||
continue
|
||||
item_name = item["name"]
|
||||
item_id = item["id"]
|
||||
item_parent_id = item["parentId"]
|
||||
item_manufacturer = None
|
||||
item_device_name = None
|
||||
item_model = None
|
||||
|
||||
for parent_item in items_of_category:
|
||||
if parent_item["id"] == item_parent_id:
|
||||
item_manufacturer = parent_item.get("manufacturer")
|
||||
item_device_name = parent_item.get("roomName")
|
||||
item_model = parent_item.get("model")
|
||||
except KeyError:
|
||||
_LOGGER.exception(
|
||||
"Unknown device properties received from Control4: %s",
|
||||
item,
|
||||
)
|
||||
continue
|
||||
|
||||
if item_id not in coordinator.data:
|
||||
_LOGGER.warning(
|
||||
"Couldn't get cover state data for %s (ID: %s), skipping setup",
|
||||
item_name,
|
||||
item_id,
|
||||
)
|
||||
continue
|
||||
|
||||
entity_list.append(
|
||||
Control4Cover(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
item_name,
|
||||
item_id,
|
||||
item_device_name,
|
||||
item_manufacturer,
|
||||
item_model,
|
||||
item_parent_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entity_list)
|
||||
|
||||
|
||||
class Control4Cover(Control4Entity, CoverEntity):
|
||||
"""Control4 cover entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "blind"
|
||||
_attr_device_class = CoverDeviceClass.SHADE
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.STOP
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._cover_data is not None
|
||||
|
||||
def _create_api_object(self) -> C4Blind:
|
||||
"""Create a pyControl4 device object.
|
||||
|
||||
This exists so the director token used is always the latest one,
|
||||
without needing to re-init the entire entity.
|
||||
"""
|
||||
return C4Blind(self.runtime_data.director, self._idx)
|
||||
|
||||
@property
|
||||
def _cover_data(self) -> dict[str, Any] | None:
|
||||
"""Return the cover data from the coordinator."""
|
||||
return self.coordinator.data.get(self._idx)
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return current position of cover (0 closed, 100 open)."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
level = data.get(CONTROL4_LEVEL)
|
||||
if level is None:
|
||||
return None
|
||||
return int(level)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
if (fully_closed := data.get(CONTROL4_FULLY_CLOSED)) is not None:
|
||||
return bool(fully_closed)
|
||||
position = self.current_cover_position
|
||||
if position is None:
|
||||
return None
|
||||
return position == 0
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return if the cover is opening."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
opening = data.get(CONTROL4_OPENING)
|
||||
if opening is None:
|
||||
return None
|
||||
return bool(opening)
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return if the cover is closing."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
closing = data.get(CONTROL4_CLOSING)
|
||||
if closing is None:
|
||||
return None
|
||||
return bool(closing)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.open()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.close()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.stop()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.setLevelTarget(kwargs[ATTR_POSITION])
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -1,48 +0,0 @@
|
||||
"""The Data Grand Lyon integration."""
|
||||
|
||||
from data_grand_lyon_ha import DataGrandLyonClient
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Data Grand Lyon from a config entry."""
|
||||
session = async_get_clientsession(hass)
|
||||
client = DataGrandLyonClient(
|
||||
session=session,
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
coordinator = DataGrandLyonCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_entry))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_entry(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry update (e.g., subentry changes)."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Config flow for the Data Grand Lyon integration."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from data_grand_lyon_ha import DataGrandLyonClient, TclPassageType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, SUBENTRY_TYPE_STOP
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_STOP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LINE): str,
|
||||
vol.Required(CONF_STOP_ID): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Data Grand Lyon."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentry types supported by this integration."""
|
||||
return {
|
||||
SUBENTRY_TYPE_STOP: StopSubentryFlowHandler,
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
|
||||
|
||||
if error := await self._test_connection(user_input):
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_create_entry(title="Data Grand Lyon", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication with new credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if user_input is not None:
|
||||
if error := await self._test_connection(user_input):
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry, data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA,
|
||||
{CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
creds = {
|
||||
CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
if error := await self._test_connection(creds):
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_RECONFIGURE_SCHEMA,
|
||||
user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
|
||||
"""Test connectivity by making a dummy API call.
|
||||
|
||||
Returns None on success, or an error key for the errors dict.
|
||||
"""
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = DataGrandLyonClient(
|
||||
session=session,
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
try:
|
||||
# the upstream library filters in memory so these placeholder values
|
||||
# won't trigger an exception ; the returned list will be empty
|
||||
await client.get_tcl_passages(
|
||||
ligne="__test__", stop_id=0, passage_type=TclPassageType.ESTIMATED
|
||||
)
|
||||
except ClientResponseError as err:
|
||||
if err.status in (401, 403):
|
||||
return "invalid_auth"
|
||||
return "cannot_connect"
|
||||
except ClientError, TimeoutError:
|
||||
return "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error testing Data Grand Lyon connection")
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
|
||||
class StopSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle a subentry flow for adding a Data Grand Lyon stop."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle the user step to add a new stop."""
|
||||
entry = self._get_entry()
|
||||
|
||||
if user_input is not None:
|
||||
line = user_input[CONF_LINE]
|
||||
stop_id = user_input[CONF_STOP_ID]
|
||||
unique_id = f"{line}_{stop_id}"
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if subentry.unique_id == unique_id:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
name = f"{line} - Stop {stop_id}"
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={CONF_LINE: line, CONF_STOP_ID: stop_id},
|
||||
unique_id=unique_id,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_STOP_DATA_SCHEMA,
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Constants for the Data Grand Lyon integration."""
|
||||
|
||||
import logging
|
||||
|
||||
DOMAIN = "data_grand_lyon"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
SUBENTRY_TYPE_STOP = "stop"
|
||||
|
||||
CONF_LINE = "line"
|
||||
CONF_STOP_ID = "stop_id"
|
||||
@@ -1,83 +0,0 @@
|
||||
"""DataUpdateCoordinator for the Data Grand Lyon integration."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from data_grand_lyon_ha import DataGrandLyonClient, TclPassage
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_LINE, CONF_STOP_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_STOP
|
||||
|
||||
type DataGrandLyonConfigEntry = ConfigEntry[DataGrandLyonCoordinator]
|
||||
|
||||
|
||||
class DataGrandLyonCoordinator(DataUpdateCoordinator[dict[str, list[TclPassage]]]):
|
||||
"""Coordinator for the Data Grand Lyon integration."""
|
||||
|
||||
config_entry: DataGrandLyonConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: DataGrandLyonConfigEntry,
|
||||
client: DataGrandLyonClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.client = client
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, list[TclPassage]]:
|
||||
"""Fetch data for all monitored stops."""
|
||||
stop_subentries = list(
|
||||
self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_STOP)
|
||||
)
|
||||
|
||||
stop_tasks = [
|
||||
self.client.get_tcl_passages(
|
||||
ligne=subentry.data[CONF_LINE],
|
||||
stop_id=subentry.data[CONF_STOP_ID],
|
||||
)
|
||||
for subentry in stop_subentries
|
||||
]
|
||||
|
||||
stop_results: list[list[TclPassage] | BaseException] = await asyncio.gather(
|
||||
*stop_tasks, return_exceptions=True
|
||||
)
|
||||
|
||||
stops: dict[str, list[TclPassage]] = {}
|
||||
for i, subentry in enumerate(stop_subentries):
|
||||
result = stop_results[i]
|
||||
if isinstance(result, BaseException):
|
||||
if isinstance(result, ClientResponseError) and result.status in (
|
||||
401,
|
||||
403,
|
||||
):
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from result
|
||||
LOGGER.warning(
|
||||
"Error fetching departures for stop %s: %s",
|
||||
subentry.subentry_id,
|
||||
result,
|
||||
)
|
||||
continue
|
||||
stops[subentry.subentry_id] = result
|
||||
|
||||
if stop_subentries and not stops:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed_all_stops",
|
||||
)
|
||||
return stops
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Diagnostics support for the Data Grand Lyon integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import DataGrandLyonConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"coordinator_data": {
|
||||
subentry_id: [asdict(passage) for passage in passages]
|
||||
for subentry_id, passages in coordinator.data.items()
|
||||
},
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"next_departure_1": {
|
||||
"default": "mdi:bus-clock"
|
||||
},
|
||||
"next_departure_1_direction": {
|
||||
"default": "mdi:directions"
|
||||
},
|
||||
"next_departure_1_type": {
|
||||
"default": "mdi:clock-outline",
|
||||
"state": {
|
||||
"estimated": "mdi:clock-check-outline"
|
||||
}
|
||||
},
|
||||
"next_departure_2": {
|
||||
"default": "mdi:bus-clock"
|
||||
},
|
||||
"next_departure_2_direction": {
|
||||
"default": "mdi:directions"
|
||||
},
|
||||
"next_departure_2_type": {
|
||||
"default": "mdi:clock-outline",
|
||||
"state": {
|
||||
"estimated": "mdi:clock-check-outline"
|
||||
}
|
||||
},
|
||||
"next_departure_3": {
|
||||
"default": "mdi:bus-clock"
|
||||
},
|
||||
"next_departure_3_direction": {
|
||||
"default": "mdi:directions"
|
||||
},
|
||||
"next_departure_3_type": {
|
||||
"default": "mdi:clock-outline",
|
||||
"state": {
|
||||
"estimated": "mdi:clock-check-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"domain": "data_grand_lyon",
|
||||
"name": "Data Grand Lyon",
|
||||
"codeowners": ["@Crocmagnon"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/data_grand_lyon",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["data-grand-lyon-ha==0.5.0"]
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not register 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 register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Entities use the coordinator pattern and do not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -1,180 +0,0 @@
|
||||
"""Sensor platform for the Data Grand Lyon integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from data_grand_lyon_ha import TclPassage, TclPassageType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, SUBENTRY_TYPE_STOP
|
||||
from .coordinator import DataGrandLyonConfigEntry, DataGrandLyonCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_TZ_PARIS = ZoneInfo("Europe/Paris")
|
||||
|
||||
_DEPARTURE_TYPE_OPTIONS = [t.name.lower() for t in TclPassageType]
|
||||
|
||||
|
||||
def _departure_time(departure: TclPassage) -> datetime:
|
||||
"""Return the departure time, localized to Europe/Paris if naive."""
|
||||
dt = departure.heure_passage
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=_TZ_PARIS)
|
||||
return dt
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class DataGrandLyonStopSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a Data Grand Lyon stop departure sensor entity."""
|
||||
|
||||
departure_index: int
|
||||
value_fn: Callable[[TclPassage], StateType | datetime]
|
||||
|
||||
|
||||
STOP_SENSOR_DESCRIPTIONS: tuple[DataGrandLyonStopSensorEntityDescription, ...] = (
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_1",
|
||||
translation_key="next_departure_1",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
departure_index=0,
|
||||
value_fn=_departure_time,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_1_direction",
|
||||
translation_key="next_departure_1_direction",
|
||||
departure_index=0,
|
||||
value_fn=lambda p: p.direction,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_1_type",
|
||||
translation_key="next_departure_1_type",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=_DEPARTURE_TYPE_OPTIONS,
|
||||
departure_index=0,
|
||||
value_fn=lambda p: p.type.name.lower(),
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_2",
|
||||
translation_key="next_departure_2",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
departure_index=1,
|
||||
value_fn=_departure_time,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_2_direction",
|
||||
translation_key="next_departure_2_direction",
|
||||
departure_index=1,
|
||||
value_fn=lambda p: p.direction,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_2_type",
|
||||
translation_key="next_departure_2_type",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=_DEPARTURE_TYPE_OPTIONS,
|
||||
departure_index=1,
|
||||
value_fn=lambda p: p.type.name.lower(),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_3",
|
||||
translation_key="next_departure_3",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
departure_index=2,
|
||||
value_fn=_departure_time,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_3_direction",
|
||||
translation_key="next_departure_3_direction",
|
||||
departure_index=2,
|
||||
value_fn=lambda p: p.direction,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
DataGrandLyonStopSensorEntityDescription(
|
||||
key="next_departure_3_type",
|
||||
translation_key="next_departure_3_type",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=_DEPARTURE_TYPE_OPTIONS,
|
||||
departure_index=2,
|
||||
value_fn=lambda p: p.type.name.lower(),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DataGrandLyonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Data Grand Lyon sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STOP):
|
||||
async_add_entities(
|
||||
(
|
||||
DataGrandLyonStopSensor(coordinator, subentry, description)
|
||||
for description in STOP_SENSOR_DESCRIPTIONS
|
||||
),
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class DataGrandLyonStopSensor(
|
||||
CoordinatorEntity[DataGrandLyonCoordinator], SensorEntity
|
||||
):
|
||||
"""Sensor for Data Grand Lyon stop departures."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: DataGrandLyonStopSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DataGrandLyonCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
description: DataGrandLyonStopSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._subentry_id = subentry.subentry_id
|
||||
assert subentry.unique_id is not None
|
||||
|
||||
self._attr_unique_id = f"{subentry.unique_id}-{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.unique_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="TCL",
|
||||
model="Stop",
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
def _get_departure(self) -> TclPassage | None:
|
||||
"""Return the departure for this sensor's index, or None."""
|
||||
departures = self.coordinator.data.get(self._subentry_id, [])
|
||||
index = self.entity_description.departure_index
|
||||
if index >= len(departures):
|
||||
return None
|
||||
return departures[index]
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the sensor value."""
|
||||
departure = self._get_departure()
|
||||
if departure is None:
|
||||
return None
|
||||
return self.entity_description.value_fn(departure)
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Your password on data.grandlyon.com.",
|
||||
"username": "Your username on data.grandlyon.com."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"stop": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"entry_type": "Transit stop",
|
||||
"initiate_flow": {
|
||||
"user": "Add transit stop"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"line": "Line",
|
||||
"stop_id": "Stop ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"next_departure_1": {
|
||||
"name": "Next departure 1"
|
||||
},
|
||||
"next_departure_1_direction": {
|
||||
"name": "Next departure 1 direction"
|
||||
},
|
||||
"next_departure_1_type": {
|
||||
"name": "Next departure 1 type",
|
||||
"state": {
|
||||
"estimated": "Estimated",
|
||||
"theoretical": "Theoretical"
|
||||
}
|
||||
},
|
||||
"next_departure_2": {
|
||||
"name": "Next departure 2"
|
||||
},
|
||||
"next_departure_2_direction": {
|
||||
"name": "Next departure 2 direction"
|
||||
},
|
||||
"next_departure_2_type": {
|
||||
"name": "Next departure 2 type",
|
||||
"state": {
|
||||
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
|
||||
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
|
||||
}
|
||||
},
|
||||
"next_departure_3": {
|
||||
"name": "Next departure 3"
|
||||
},
|
||||
"next_departure_3_direction": {
|
||||
"name": "Next departure 3 direction"
|
||||
},
|
||||
"next_departure_3_type": {
|
||||
"name": "Next departure 3 type",
|
||||
"state": {
|
||||
"estimated": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::estimated%]",
|
||||
"theoretical": "[%key:component::data_grand_lyon::entity::sensor::next_departure_1_type::state::theoretical%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed for Data Grand Lyon."
|
||||
},
|
||||
"update_failed_all_stops": {
|
||||
"message": "Error fetching Data Grand Lyon data: all requests failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The Denon RS-232 integration."""
|
||||
"""The Denon RS232 integration."""
|
||||
|
||||
from denon_rs232 import DenonReceiver, ReceiverState
|
||||
from denon_rs232.models import MODELS
|
||||
@@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Set up Denon RS-232 from a config entry."""
|
||||
"""Set up Denon RS232 from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
model = MODELS[entry.data[CONF_MODEL]]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for the Denon RS-232 integration."""
|
||||
"""Config flow for the Denon RS232 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -63,7 +63,7 @@ async def _async_attempt_connect(port: str, model_key: str) -> str | None:
|
||||
|
||||
|
||||
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon RS-232."""
|
||||
"""Handle a config flow for Denon RS232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the Denon RS-232 integration."""
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS-232",
|
||||
"name": "Denon RS232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Media player platform for the Denon RS-232 integration."""
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
@@ -77,7 +77,7 @@ async def async_setup_entry(
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon RS-232 media player."""
|
||||
"""Set up the Denon RS232 media player."""
|
||||
receiver = config_entry.runtime_data
|
||||
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon receiver controlled over RS-232."""
|
||||
"""Representation of a Denon receiver controlled over RS232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@@ -16,11 +16,9 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_PORT
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import SectionConfig, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
CONF_ADVANCED_OPTIONS,
|
||||
CONF_HOSTNAME,
|
||||
CONF_IPV4,
|
||||
CONF_IPV6,
|
||||
@@ -39,17 +37,15 @@ from .const import (
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
|
||||
vol.Required(CONF_ADVANCED_OPTIONS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_RESOLVER): cv.string,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
|
||||
vol.Optional(CONF_PORT_IPV6): cv.port,
|
||||
}
|
||||
),
|
||||
SectionConfig(collapsed=True),
|
||||
),
|
||||
}
|
||||
)
|
||||
DATA_SCHEMA_ADV = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
|
||||
vol.Optional(CONF_RESOLVER): cv.string,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_RESOLVER_IPV6): cv.string,
|
||||
vol.Optional(CONF_PORT_IPV6): cv.port,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -115,13 +111,10 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input:
|
||||
hostname = user_input[CONF_HOSTNAME]
|
||||
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
|
||||
advanced_options = user_input[CONF_ADVANCED_OPTIONS]
|
||||
resolver = advanced_options.get(CONF_RESOLVER, DEFAULT_RESOLVER)
|
||||
resolver_ipv6 = advanced_options.get(
|
||||
CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6
|
||||
)
|
||||
port = advanced_options.get(CONF_PORT, DEFAULT_PORT)
|
||||
port_ipv6 = advanced_options.get(CONF_PORT_IPV6, DEFAULT_PORT)
|
||||
resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER)
|
||||
resolver_ipv6 = user_input.get(CONF_RESOLVER_IPV6, DEFAULT_RESOLVER_IPV6)
|
||||
port = user_input.get(CONF_PORT, DEFAULT_PORT)
|
||||
port_ipv6 = user_input.get(CONF_PORT_IPV6, DEFAULT_PORT)
|
||||
|
||||
validate = await async_validate_hostname(
|
||||
hostname, resolver, resolver_ipv6, port, port_ipv6
|
||||
@@ -156,6 +149,12 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
if self.show_advanced_options is True:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA_ADV,
|
||||
errors=errors,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
|
||||
@@ -12,7 +12,6 @@ CONF_PORT_IPV6 = "port_ipv6"
|
||||
CONF_IPV4 = "ipv4"
|
||||
CONF_IPV6 = "ipv6"
|
||||
CONF_IPV6_V4 = "ipv6_v4"
|
||||
CONF_ADVANCED_OPTIONS = "advanced_options"
|
||||
|
||||
DEFAULT_HOSTNAME = "myip.opendns.com"
|
||||
DEFAULT_IPV6 = False
|
||||
|
||||
@@ -9,28 +9,18 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"hostname": "Hostname"
|
||||
"hostname": "Hostname",
|
||||
"port": "IPv4 port",
|
||||
"port_ipv6": "IPv6 port",
|
||||
"resolver": "IPv4 resolver",
|
||||
"resolver_ipv6": "IPv6 resolver"
|
||||
},
|
||||
"data_description": {
|
||||
"hostname": "The hostname for which to perform the DNS query."
|
||||
},
|
||||
"sections": {
|
||||
"advanced_options": {
|
||||
"data": {
|
||||
"port": "IPv4 port",
|
||||
"port_ipv6": "IPv6 port",
|
||||
"resolver": "IPv4 resolver",
|
||||
"resolver_ipv6": "IPv6 resolver"
|
||||
},
|
||||
"data_description": {
|
||||
"port": "Port used for the IPv4 lookup.",
|
||||
"port_ipv6": "Port used for the IPv6 lookup.",
|
||||
"resolver": "Resolver used for the IPv4 lookup.",
|
||||
"resolver_ipv6": "Resolver used for the IPv6 lookup."
|
||||
},
|
||||
"description": "Optionally change resolvers and ports.",
|
||||
"name": "Advanced options"
|
||||
}
|
||||
"hostname": "The hostname for which to perform the DNS query.",
|
||||
"port": "Port used for the IPv4 lookup.",
|
||||
"port_ipv6": "Port used for the IPv6 lookup.",
|
||||
"resolver": "Resolver used for the IPv4 lookup.",
|
||||
"resolver_ipv6": "Resolver used for the IPv6 lookup."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,18 +53,17 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data::resolver_ipv6%]"
|
||||
"port": "[%key:component::dnsip::config::step::user::data::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::data::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::data::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]"
|
||||
},
|
||||
"data_description": {
|
||||
"port": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::sections::advanced_options::data_description::resolver_ipv6%]"
|
||||
},
|
||||
"description": "Optionally change resolvers and ports."
|
||||
"port": "[%key:component::dnsip::config::step::user::data_description::port%]",
|
||||
"port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]",
|
||||
"resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]",
|
||||
"resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -16,13 +17,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
|
||||
@@ -1,34 +1,22 @@
|
||||
"""The Duco integration."""
|
||||
|
||||
import re
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
from duco import DucoClient, build_ssl_context
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
|
||||
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Set up Duco from a config entry."""
|
||||
# Remove entity registry entries for the temperature and box_temperature
|
||||
# sensors that were removed when migrating to python-duco-connectivity.
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
):
|
||||
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
|
||||
ssl_context = await hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(hass),
|
||||
host=entry.data[CONF_HOST],
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
coordinator = DucoCoordinator(hass, entry, client)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco import DucoClient, build_ssl_context
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -158,9 +158,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Returns a tuple of (box_name, mac_address).
|
||||
"""
|
||||
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
lan_info = await client.async_get_lan_info()
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.models import BoardInfo, Node
|
||||
from duco import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
from duco.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
|
||||
api_info_obj = await coordinator.client.async_get_api_info()
|
||||
lan_info = await coordinator.client.async_get_lan_info()
|
||||
duco_diags = await coordinator.client.async_get_diagnostics()
|
||||
write_remaining = await coordinator.client.async_get_write_requests_remaining()
|
||||
write_remaining = await coordinator.client.async_get_write_req_remaining()
|
||||
except DucoConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco_connectivity.models import Node
|
||||
from duco.models import Node
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
from duco.exceptions import DucoError, DucoRateLimitError
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco_connectivity"],
|
||||
"loggers": ["duco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-connectivity==0.2.0"],
|
||||
"requirements": ["python-duco-client==0.4.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -18,6 +18,7 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -53,18 +54,20 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
key="ventilation_state",
|
||||
translation_key="ventilation_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
state.lower()
|
||||
for state in VentilationState
|
||||
if state != VentilationState.UNKNOWN
|
||||
],
|
||||
options=[s.lower() for s in VentilationState],
|
||||
value_fn=lambda node: (
|
||||
node.ventilation.state.lower()
|
||||
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
|
||||
else None
|
||||
node.ventilation.state.lower() if node.ventilation else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="target_flow_level",
|
||||
translation_key="target_flow_level",
|
||||
@@ -89,6 +92,17 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="box_temperature",
|
||||
translation_key="box_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"box_temperature": {
|
||||
"name": "Box temperature"
|
||||
},
|
||||
"iaq_co2": {
|
||||
"name": "CO2 air quality index"
|
||||
},
|
||||
@@ -99,10 +102,5 @@
|
||||
"rate_limit_exceeded": {
|
||||
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
"info": {
|
||||
"write_requests_remaining": "Remaining write requests today"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Provide info to system health."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoConfigEntry
|
||||
|
||||
|
||||
@callback
|
||||
def async_register(
|
||||
hass: HomeAssistant, register: system_health.SystemHealthRegistration
|
||||
) -> None:
|
||||
"""Register system health callbacks."""
|
||||
register.async_register_info(system_health_info)
|
||||
|
||||
|
||||
async def _async_get_write_requests_remaining(
|
||||
config_entry: DucoConfigEntry,
|
||||
) -> int | dict[str, str]:
|
||||
"""Get the remaining write-request quota for system health."""
|
||||
try:
|
||||
return (
|
||||
await config_entry.runtime_data.client.async_get_write_requests_remaining()
|
||||
)
|
||||
except DucoConnectionError:
|
||||
return {"type": "failed", "error": "unreachable"}
|
||||
|
||||
|
||||
async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
"""Get info for the info page."""
|
||||
config_entries: list[DucoConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
|
||||
if not config_entries:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"write_requests_remaining": _async_get_write_requests_remaining(
|
||||
config_entries[0]
|
||||
)
|
||||
}
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from easyenergy import EasyEnergy, EasyEnergyConnectionError
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -20,22 +16,14 @@ class EasyEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if user_input is not None:
|
||||
easyenergy = EasyEnergy(session=async_get_clientsession(self.hass))
|
||||
today = dt_util.now().date()
|
||||
try:
|
||||
await easyenergy.energy_prices(start_date=today, end_date=today)
|
||||
except EasyEnergyConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="easyEnergy",
|
||||
data={},
|
||||
)
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="user")
|
||||
|
||||
return self.async_show_form(step_id="user", errors=errors)
|
||||
return self.async_create_entry(
|
||||
title="easyEnergy",
|
||||
data={},
|
||||
)
|
||||
|
||||
@@ -76,10 +76,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
|
||||
)
|
||||
|
||||
except EasyEnergyConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
raise UpdateFailed("Error communicating with easyEnergy API") from err
|
||||
|
||||
return EasyEnergyData(
|
||||
energy_today=energy_today,
|
||||
|
||||
@@ -1,33 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"average_price": {
|
||||
"default": "mdi:cash-multiple"
|
||||
},
|
||||
"current_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"highest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"hours_priced_equal_or_higher": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"hours_priced_equal_or_lower": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"lowest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"max_price": {
|
||||
"default": "mdi:cash-plus"
|
||||
},
|
||||
"min_price": {
|
||||
"default": "mdi:cash-minus"
|
||||
},
|
||||
"next_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"percentage_of_max": {
|
||||
"default": "mdi:percent"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/easyenergy",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["easyenergy==3.0.1"],
|
||||
"requirements": ["easyenergy==3.0.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ from .coordinator import (
|
||||
EasyEnergyDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EasyEnergySensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
@@ -47,9 +44,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_error": {
|
||||
"message": "Error communicating with the easyEnergy API."
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid date provided. Got {date}"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==18.2.0"]
|
||||
}
|
||||
|
||||
@@ -72,11 +72,9 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SCENE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Support for ElkM1 number entities."""
|
||||
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from elkm1_lib.const import SettingFormat
|
||||
from elkm1_lib.elements import Element
|
||||
from elkm1_lib.settings import Setting
|
||||
|
||||
from homeassistant.components.number import NumberDeviceClass, NumberEntity
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
||||
from .models import ELKM1Data
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ElkM1ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Elk-M1 number platform."""
|
||||
elk_data = config_entry.runtime_data
|
||||
elk = elk_data.elk
|
||||
entities: list[ElkEntity] = []
|
||||
number_settings = [
|
||||
setting
|
||||
for setting in cast(list[Setting], elk.settings)
|
||||
if setting.value_format in (SettingFormat.NUMBER, SettingFormat.TIMER)
|
||||
]
|
||||
|
||||
create_elk_entities(
|
||||
elk_data,
|
||||
number_settings,
|
||||
"setting",
|
||||
ElkNumberSetting,
|
||||
entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ElkNumberSetting(ElkAttachedEntity, NumberEntity):
|
||||
"""Representation of an Elk-M1 Number Setting."""
|
||||
|
||||
_element: Setting
|
||||
|
||||
_attr_native_min_value = 0
|
||||
_attr_native_max_value = 65535
|
||||
_attr_native_step = 1
|
||||
|
||||
def __init__(self, element: Setting, elk: Any, elk_data: ELKM1Data) -> None:
|
||||
"""Initialize the number setting."""
|
||||
super().__init__(element, elk, elk_data)
|
||||
if element.value_format == SettingFormat.TIMER:
|
||||
self._attr_device_class = NumberDeviceClass.DURATION
|
||||
self._attr_native_unit_of_measurement = UnitOfTime.SECONDS
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
# Guard against the panel possibly changing the underlying
|
||||
# type without us knowing about the change
|
||||
if isinstance(self._element.value, int):
|
||||
self._attr_native_value = self._element.value
|
||||
else:
|
||||
self._attr_available = False
|
||||
_LOGGER.warning(
|
||||
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the value of the setting."""
|
||||
self._element.set(int(value))
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Support for ElkM1 time entities."""
|
||||
|
||||
from datetime import time as dt_time
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from elkm1_lib.const import SettingFormat
|
||||
from elkm1_lib.elements import Element
|
||||
from elkm1_lib.settings import Setting
|
||||
|
||||
from homeassistant.components.time import TimeEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ElkM1ConfigEntry
|
||||
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ElkM1ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Elk-M1 time platform."""
|
||||
elk_data = config_entry.runtime_data
|
||||
elk = elk_data.elk
|
||||
entities: list[ElkEntity] = []
|
||||
time_settings = [
|
||||
setting
|
||||
for setting in cast(list[Setting], elk.settings)
|
||||
if setting.value_format == SettingFormat.TIME_OF_DAY
|
||||
]
|
||||
|
||||
create_elk_entities(
|
||||
elk_data,
|
||||
time_settings,
|
||||
"setting",
|
||||
ElkTimeSetting,
|
||||
entities,
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ElkTimeSetting(ElkAttachedEntity, TimeEntity):
|
||||
"""Representation of an Elk-M1 Time Setting."""
|
||||
|
||||
_element: Setting
|
||||
|
||||
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
|
||||
value = self._element.value
|
||||
# Guard against the panel possibly changing the underlying
|
||||
# type without us knowing about the change
|
||||
if isinstance(value, tuple):
|
||||
self._attr_native_value = dt_time(hour=value[0], minute=value[1])
|
||||
else:
|
||||
self._attr_available = False
|
||||
_LOGGER.warning(
|
||||
"Setting type for '%s' differs between the ElkM1 and the entity. Restart the integration to fix",
|
||||
self.entity_id,
|
||||
)
|
||||
|
||||
async def async_set_value(self, value: dt_time) -> None:
|
||||
"""Set the time of the setting."""
|
||||
self._element.set((value.hour, value.minute))
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.core import callback
|
||||
|
||||
from .entity import (
|
||||
@@ -19,10 +19,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
|
||||
"""ESPHome infrared entity using native API."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .manager import async_replace_device
|
||||
@@ -42,7 +43,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -51,7 +52,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_migrate(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the migrate step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -65,7 +66,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the manual step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -8,7 +8,6 @@ from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, TypedDict, cast
|
||||
from xml.etree.ElementTree import ParseError
|
||||
|
||||
from fritzconnection import FritzConnection
|
||||
from fritzconnection.core.exceptions import FritzActionError
|
||||
@@ -25,7 +24,7 @@ from homeassistant.components.device_tracker import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -227,13 +226,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
self.fritz_guest_wifi = FritzGuestWLAN(fc=self.connection)
|
||||
self.fritz_status = FritzStatus(fc=self.connection)
|
||||
self.fritz_call = FritzCall(fc=self.connection)
|
||||
try:
|
||||
info = self.fritz_status.get_device_info()
|
||||
except ParseError as ex:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_parse_device_info",
|
||||
) from ex
|
||||
info = self.fritz_status.get_device_info()
|
||||
|
||||
_LOGGER.debug(
|
||||
"gathered device info of %s %s",
|
||||
|
||||
@@ -185,9 +185,6 @@
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
},
|
||||
"error_parse_device_info": {
|
||||
"message": "Error parsing device info. Please check the system event log of your FRITZ!Box for malformed data and clear the event list."
|
||||
},
|
||||
"error_refresh_hosts_info": {
|
||||
"message": "Error refreshing hosts info"
|
||||
},
|
||||
|
||||
@@ -16,8 +16,6 @@ from .coordinator import FritzboxDataUpdateCoordinator
|
||||
class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
|
||||
"""Basis FritzBox entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FritzboxDataUpdateCoordinator,
|
||||
@@ -29,9 +27,11 @@ class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
|
||||
|
||||
self.ain = ain
|
||||
if entity_description is not None:
|
||||
self._attr_has_entity_name = True
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{ain}_{entity_description.key}"
|
||||
else:
|
||||
self._attr_name = self.data.name
|
||||
self._attr_unique_id = ain
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["afsapi"],
|
||||
"requirements": ["afsapi==1.0.1"],
|
||||
"requirements": ["afsapi==1.0.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
|
||||
|
||||
@@ -196,9 +196,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
|
||||
if not self._attr_source_list:
|
||||
self.__modes_by_label = {
|
||||
(mode.label or mode.id): mode.key
|
||||
for mode in await afsapi.get_modes()
|
||||
if mode.selectable
|
||||
(mode.label or mode.id): mode.key for mode in await afsapi.get_modes()
|
||||
}
|
||||
self._attr_source_list = list(self.__modes_by_label)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_PRESET_MODE,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
|
||||
PRESET_NONE,
|
||||
@@ -452,9 +451,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
return
|
||||
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
|
||||
self._target_temp = temperature
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
await self._async_control_heating(force=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1201,17 +1201,6 @@ class TemperatureSettingTrait(_Trait):
|
||||
preset_to_google = {climate.PRESET_ECO: "eco"}
|
||||
google_to_preset = {value: key for key, value in preset_to_google.items()}
|
||||
|
||||
action_to_google = {
|
||||
climate.HVACAction.OFF: "off",
|
||||
climate.HVACAction.HEATING: "heat",
|
||||
climate.HVACAction.DEFROSTING: "heat",
|
||||
climate.HVACAction.PREHEATING: "heat",
|
||||
climate.HVACAction.COOLING: "cool",
|
||||
climate.HVACAction.DRYING: "dry",
|
||||
climate.HVACAction.FAN: "fan-only",
|
||||
climate.HVACAction.IDLE: "none",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
@@ -1295,11 +1284,6 @@ class TemperatureSettingTrait(_Trait):
|
||||
else:
|
||||
response["thermostatMode"] = self.hvac_to_google.get(operation, "none")
|
||||
|
||||
if (
|
||||
action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION))
|
||||
) is not None:
|
||||
response["activeThermostatMode"] = action
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
response["thermostatTemperatureAmbient"] = round(
|
||||
|
||||
@@ -8,9 +8,10 @@ from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_list
|
||||
from .const import (
|
||||
@@ -76,7 +77,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
return placeholders or None
|
||||
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
|
||||
"""Return form for suggestion."""
|
||||
return self.async_show_form(
|
||||
step_id=suggestion.key,
|
||||
@@ -85,7 +86,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
# Out of sync with supervisor, issue is resolved or not fixable. Remove it
|
||||
if not self.issue or not self.issue.suggestions:
|
||||
@@ -107,7 +108,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
# Always show a form for one suggestion to explain to user what's happening
|
||||
return self._async_form_for_suggestion(self.issue.suggestions[0])
|
||||
|
||||
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
|
||||
async def async_step_fix_menu(self, _: None = None) -> FlowResult:
|
||||
"""Show the fix menu."""
|
||||
assert self.issue
|
||||
|
||||
@@ -119,7 +120,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
async def _async_step_apply_suggestion(
|
||||
self, suggestion: Suggestion, confirmed: bool = False
|
||||
) -> RepairsFlowResult:
|
||||
) -> FlowResult:
|
||||
"""Handle applying a suggestion as a flow step. Optionally request confirmation."""
|
||||
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
|
||||
return self._async_form_for_suggestion(suggestion)
|
||||
@@ -136,13 +137,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
suggestion: Suggestion,
|
||||
) -> Callable[
|
||||
[SupervisorIssueRepairFlow, dict[str, str] | None],
|
||||
Coroutine[Any, Any, RepairsFlowResult],
|
||||
Coroutine[Any, Any, FlowResult],
|
||||
]:
|
||||
"""Generate a step handler for a suggestion."""
|
||||
|
||||
async def _async_step(
|
||||
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
) -> FlowResult:
|
||||
"""Handle a flow step for a suggestion."""
|
||||
return await self._async_step_apply_suggestion(
|
||||
suggestion, confirmed=user_input is not None
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
Folder,
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
@@ -71,31 +70,6 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
|
||||
# for backwards compatibility with existing automations.
|
||||
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
|
||||
|
||||
|
||||
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map legacy aliases used by both partial backup and partial restore handlers."""
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
if ATTR_FOLDERS in data:
|
||||
folders: set[Any] = set(data[ATTR_FOLDERS])
|
||||
if LEGACY_FOLDER_HOMEASSISTANT in folders:
|
||||
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
|
||||
if data.get(ATTR_HOMEASSISTANT) is False:
|
||||
raise ServiceValidationError(
|
||||
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
|
||||
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
|
||||
)
|
||||
data[ATTR_HOMEASSISTANT] = True
|
||||
if folders:
|
||||
data[ATTR_FOLDERS] = folders
|
||||
else:
|
||||
data.pop(ATTR_FOLDERS)
|
||||
return data
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
@@ -139,10 +113,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -165,10 +136,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -375,7 +343,9 @@ def async_register_backup_restore_services(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = _normalize_partial_options_data(service.data.copy())
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
@@ -422,7 +392,8 @@ def async_register_backup_restore_services(
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
data = _normalize_partial_options_data(data)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
|
||||
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
|
||||
from pycec.const import (
|
||||
CMD_STANDBY,
|
||||
KEY_BACKWARD,
|
||||
KEY_FORWARD,
|
||||
KEY_MUTE_TOGGLE,
|
||||
@@ -94,7 +93,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._device.turn_off()
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycec.commands import CecCommand
|
||||
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
|
||||
from pycec.const import POWER_OFF, POWER_ON
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -51,7 +50,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._device.turn_off()
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user