mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 19:53:18 +02:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03eb59026f | |||
| db181978f3 | |||
| 325531bdf2 | |||
| 07dee1293d | |||
| 6e497cb95d | |||
| df01a9208d | |||
| 60956c110b | |||
| af41b704d5 | |||
| d5f2cd8b17 | |||
| f96afda959 | |||
| 94bf13c6bb | |||
| b7dca79743 | |||
| df84d7a32d | |||
| c217acd7ab | |||
| f008f1501f | |||
| 739a5780b7 | |||
| 0ef221611a | |||
| 59e04c2169 | |||
| 5b0bf09fdc | |||
| c07d176467 | |||
| c39f0127ca | |||
| cdf5d39f57 | |||
| 90b6aa4d91 | |||
| f8ebc6c1e2 | |||
| e4b4503c10 | |||
| 7db1c855c1 | |||
| aa45f90a87 | |||
| cd945a42e6 | |||
| afc97268de | |||
| 497faeb103 | |||
| 84625678d3 | |||
| 95daee9f07 | |||
| ff1552e317 | |||
| ff6b69c929 | |||
| 2a74d5a81c | |||
| 52237247ae | |||
| 62d958682a | |||
| b2dad41d35 | |||
| 83c5dbb111 | |||
| cf73ef8a20 | |||
| 6555db12b1 | |||
| 20b81e9c74 | |||
| 51d004a5bb | |||
| 9c9b626ade | |||
| e0d3eb0fe3 | |||
| 5f5df558c6 | |||
| fbc5884ce8 | |||
| e72346c222 | |||
| 266f7b8dbe | |||
| 3ae4811e99 | |||
| 526ed271ae | |||
| 6c823cd970 | |||
| fb4b36b7f0 | |||
| 86898f9111 | |||
| 27969c34a5 | |||
| 74fabca890 | |||
| af6fcae8b6 | |||
| 818b420cb5 | |||
| ef2a065784 | |||
| 15943a737a | |||
| 1647c0bf84 | |||
| 42aefd67dd | |||
| c281c51fc9 | |||
| fa09c6d29a | |||
| 9f7ddcca22 | |||
| e488c7f3a5 | |||
| bb924e79b1 | |||
| 39d60faa42 | |||
| 378a26f778 | |||
| 5c12d59ab7 | |||
| c9e44d2d51 | |||
| c195ddd8f2 | |||
| 4e388e1435 | |||
| 191143d12d | |||
| bb6087cf87 | |||
| 70e18fc196 | |||
| 526ddc4770 | |||
| 5f6bd9b6a7 | |||
| 9b525bf1cb | |||
| 3bc2c0d097 | |||
| b5bdff7068 | |||
| 7103b07638 | |||
| d52c281826 | |||
| 9fca2f284b | |||
| f1986d5fc3 | |||
| ce9c83e33c | |||
| aa98fce92e | |||
| b01e56582a | |||
| 9be078475d | |||
| 9174ae4e00 | |||
| d4aa1b53f2 | |||
| ba29f210c2 | |||
| 845572927c | |||
| 9cd7ac2722 | |||
| a7fd763570 | |||
| 65491372c2 | |||
| de96ee44e5 | |||
| 6edcf5722e | |||
| e6acebb322 | |||
| 277daf2dba | |||
| 1b935314f8 | |||
| cad5c9e8fa | |||
| f7201f1910 | |||
| c406e1aeed | |||
| 946a3bcf11 | |||
| 2c8d9c7207 | |||
| db25f1911e | |||
| 7e2fa90773 | |||
| ef83ccc423 | |||
| 046b48df43 | |||
| 66cd719f85 | |||
| b0c2e57649 | |||
| cb92fa27ba | |||
| c3f8f6f310 | |||
| a82205fed7 | |||
| 776fd69e39 | |||
| 2863b59be4 | |||
| 676e9c7f29 | |||
| 05c3c058d6 | |||
| fd93f24208 | |||
| 544b21f014 | |||
| 8d30abab9e | |||
| ee19c11565 | |||
| c26eb2374d | |||
| 59bc46a9d2 | |||
| ab668ac576 | |||
| c4836600c4 | |||
| f4e0349825 | |||
| 4d578b6c98 | |||
| 741779efd7 | |||
| eb1babedfd | |||
| de0d24e91c | |||
| 0de23f2636 |
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -155,6 +155,7 @@ 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.*
|
||||
@@ -295,6 +296,7 @@ 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.*
|
||||
@@ -423,6 +425,7 @@ 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
+6
@@ -294,6 +294,8 @@ CLAUDE.md @home-assistant/core
|
||||
/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
|
||||
@@ -345,6 +347,8 @@ 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
|
||||
@@ -1308,6 +1312,8 @@ 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
|
||||
|
||||
@@ -46,7 +46,7 @@ from .components import (
|
||||
file_upload as file_upload_pre_import, # noqa: F401
|
||||
group as group_pre_import, # noqa: F401
|
||||
history as history_pre_import, # noqa: F401
|
||||
http, # not named pre_import since it has requirements
|
||||
http as http_import, # noqa: F401 - not named pre_import since it has requirements
|
||||
image_upload as image_upload_import, # noqa: F401 - not named pre_import since it has requirements
|
||||
logbook as logbook_pre_import, # noqa: F401
|
||||
lovelace as lovelace_pre_import, # noqa: F401
|
||||
@@ -403,12 +403,7 @@ async def async_setup_hass(
|
||||
_LOGGER.info("Starting in recovery mode")
|
||||
hass.config.recovery_mode = True
|
||||
|
||||
http_conf = (await http.async_get_last_config(hass)) or {}
|
||||
|
||||
await async_from_config_dict(
|
||||
{"recovery_mode": {}, "http": http_conf},
|
||||
hass,
|
||||
)
|
||||
await async_from_config_dict({"recovery_mode": {}}, hass)
|
||||
|
||||
if runtime_config.open_ui:
|
||||
hass.add_job(open_hass_ui, hass)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.1"]
|
||||
"requirements": ["serialx==1.7.2"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.4.3"]
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import anthropic
|
||||
from anthropic.resources.messages.messages import DEPRECATED_MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -41,9 +40,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str]
|
||||
) -> data_entry_flow.FlowResult:
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
@@ -5,8 +5,7 @@ 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
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
|
||||
@@ -21,14 +20,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
|
||||
raise ValueError("Missing data")
|
||||
self._data = data
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""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,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
@@ -30,7 +30,6 @@ 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 (
|
||||
@@ -142,20 +141,12 @@ 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,
|
||||
**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,
|
||||
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==69"],
|
||||
"requirements": ["axis==70"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["blebox_uniapi"],
|
||||
"requirements": ["blebox-uniapi==2.5.2"],
|
||||
"requirements": ["blebox-uniapi==2.5.3"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -118,7 +125,9 @@ 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]
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
|
||||
DEFAULT_HEATING_CIRCUITS
|
||||
)
|
||||
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
@@ -229,7 +238,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] = [1]
|
||||
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
config = BSBLANConfig(
|
||||
host=entry.data[CONF_HOST],
|
||||
passkey=entry.data[CONF_PASSKEY],
|
||||
@@ -245,11 +254,18 @@ 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 single circuit [1]. Use Reconfigure to "
|
||||
"defaulting to a single circuit. 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,
|
||||
@@ -263,4 +279,22 @@ 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,21 +13,28 @@ 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_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a BSBLAN config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize BSBLan flow."""
|
||||
self.host: str = ""
|
||||
self.port: int = DEFAULT_PORT
|
||||
self.mac: str | None = None
|
||||
self.circuits: list[int] = [1]
|
||||
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
self.passkey: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
@@ -384,6 +391,13 @@ 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,
|
||||
@@ -392,4 +406,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"Circuit discovery not available for %s, defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = [1]
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
@@ -22,4 +22,5 @@ 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.0"],
|
||||
"requirements": ["python-bsblan==5.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -21,13 +20,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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,9 +1,12 @@
|
||||
"""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
|
||||
@@ -20,12 +23,41 @@ from .const import DOMAIN
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
type CastConfigEntry = ConfigEntry[CastRuntimeData]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@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:
|
||||
"""Set up Cast from a config entry."""
|
||||
hass.data[DOMAIN] = {"cast_platform": {}, "unknown_models": {}}
|
||||
entry.runtime_data = CastRuntimeData()
|
||||
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
|
||||
|
||||
@@ -65,27 +97,13 @@ class CastProtocol(Protocol):
|
||||
"""
|
||||
|
||||
|
||||
@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:
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: CastConfigEntry) -> 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: ConfigEntry, device_entry: dr.DeviceEntry
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry, device_entry: dr.DeviceEntry
|
||||
) -> bool:
|
||||
"""Remove cast config entry from a device.
|
||||
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
"""Config flow for Cast."""
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import onboarding
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import CONF_UUID
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -19,6 +14,9 @@ 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(
|
||||
{
|
||||
@@ -40,7 +38,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: CastConfigEntry,
|
||||
) -> CastOptionsFlowHandler:
|
||||
"""Get the options flow for this handler."""
|
||||
return CastOptionsFlowHandler()
|
||||
|
||||
@@ -12,13 +12,6 @@ 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,17 +2,16 @@
|
||||
|
||||
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,
|
||||
@@ -20,11 +19,16 @@ 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
|
||||
hass: HomeAssistant,
|
||||
cast_info: pychromecast.models.CastInfo,
|
||||
config_entry: CastConfigEntry,
|
||||
) -> None:
|
||||
"""Discover a Chromecast."""
|
||||
|
||||
@@ -36,7 +40,7 @@ def discover_chromecast(
|
||||
_LOGGER.error("Discovered chromecast without uuid %s", info)
|
||||
return
|
||||
|
||||
info = info.fill_out_missing_chromecast_info(hass)
|
||||
info = info.fill_out_missing_chromecast_info(hass, config_entry)
|
||||
_LOGGER.debug("Discovered new or updated chromecast %s", info)
|
||||
|
||||
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
|
||||
@@ -49,7 +53,9 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo) -> None:
|
||||
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
|
||||
|
||||
|
||||
def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
def setup_internal_discovery(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> None:
|
||||
"""Set up the pychromecast internal discovery."""
|
||||
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
|
||||
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
|
||||
@@ -63,11 +69,11 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
|
||||
def add_cast(self, uuid, _):
|
||||
"""Handle zeroconf discovery of a new chromecast."""
|
||||
discover_chromecast(hass, browser.devices[uuid])
|
||||
discover_chromecast(hass, browser.devices[uuid], config_entry)
|
||||
|
||||
def update_cast(self, uuid, _):
|
||||
"""Handle zeroconf discovery of an updated chromecast."""
|
||||
discover_chromecast(hass, browser.devices[uuid])
|
||||
discover_chromecast(hass, browser.devices[uuid], config_entry)
|
||||
|
||||
def remove_cast(self, uuid, service, cast_info):
|
||||
"""Handle zeroconf discovery of a removed chromecast."""
|
||||
@@ -84,7 +90,7 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
ChromeCastZeroconf.get_zeroconf(),
|
||||
config_entry.data.get(CONF_KNOWN_HOSTS),
|
||||
)
|
||||
hass.data[CAST_BROWSER_KEY] = browser
|
||||
config_entry.runtime_data.browser = browser
|
||||
browser.start_discovery()
|
||||
|
||||
def stop_discovery(event):
|
||||
@@ -98,7 +104,9 @@ def setup_internal_discovery(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
config_entry.add_update_listener(config_entry_updated)
|
||||
|
||||
|
||||
async def config_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
async def config_entry_updated(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> None:
|
||||
"""Handle config entry being updated."""
|
||||
browser = hass.data[CAST_BROWSER_KEY]
|
||||
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))
|
||||
if browser := config_entry.runtime_data.browser:
|
||||
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) -> ChromecastInfo:
|
||||
def fill_out_missing_chromecast_info(
|
||||
self, hass: HomeAssistant, config_entry: CastConfigEntry
|
||||
) -> 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:
|
||||
# Uses legacy hass.data[DOMAIN] pattern
|
||||
# pylint: disable-next=hass-use-runtime-data
|
||||
unknown_models = hass.data[DOMAIN]["unknown_models"]
|
||||
unknown_models = config_entry.runtime_data.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,8 +1,10 @@
|
||||
"""Home Assistant Cast integration for Cast."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import auth, config_entries, core
|
||||
from homeassistant import auth, core
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, dispatcher, instance_id
|
||||
@@ -11,6 +13,9 @@ 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"
|
||||
@@ -21,9 +26,7 @@ NO_URL_AVAILABLE_ERROR = (
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_ha_cast(
|
||||
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
async def async_setup_ha_cast(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
|
||||
"""Set up Home Assistant Cast."""
|
||||
user_id: str | None = entry.data.get("user_id")
|
||||
user: auth.models.User | None = None
|
||||
@@ -87,9 +90,7 @@ async def async_setup_ha_cast(
|
||||
)
|
||||
|
||||
|
||||
async def async_remove_user(
|
||||
hass: core.HomeAssistant, entry: config_entries.ConfigEntry
|
||||
):
|
||||
async def async_remove_user(hass: core.HomeAssistant, entry: CastConfigEntry) -> None:
|
||||
"""Remove Home Assistant Cast user."""
|
||||
user_id: str | None = entry.data.get("user_id")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""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
|
||||
@@ -42,7 +41,6 @@ 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,
|
||||
@@ -58,8 +56,6 @@ 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,
|
||||
@@ -78,7 +74,7 @@ from .helpers import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CastProtocol
|
||||
from . import CastConfigEntry, CastProtocol
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,7 +106,9 @@ def api_error[_CastDeviceT: CastDevice, **_P, _R](
|
||||
|
||||
|
||||
@callback
|
||||
def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
|
||||
def _async_create_cast_device(
|
||||
hass: HomeAssistant, config_entry: CastConfigEntry, info: ChromecastInfo
|
||||
):
|
||||
"""Create a CastDevice entity or dynamic group from the chromecast object.
|
||||
|
||||
Returns None if the cast device has already been added.
|
||||
@@ -121,7 +119,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
|
||||
return None
|
||||
|
||||
# Found a cast with UUID
|
||||
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
|
||||
added_casts = config_entry.runtime_data.added_cast_devices
|
||||
if info.uuid in added_casts:
|
||||
# Already added this one, the entity will take care of moved hosts
|
||||
# itself
|
||||
@@ -131,21 +129,19 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
|
||||
|
||||
if info.is_dynamic_group:
|
||||
# This is a dynamic group, do not add it but connect to the service.
|
||||
group = DynamicCastGroup(hass, info)
|
||||
group = DynamicCastGroup(hass, config_entry, info)
|
||||
group.async_setup()
|
||||
return None
|
||||
|
||||
return CastMediaPlayerEntity(hass, info)
|
||||
return CastMediaPlayerEntity(hass, config_entry, info)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: CastConfigEntry,
|
||||
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 []
|
||||
|
||||
@@ -160,7 +156,7 @@ async def async_setup_entry(
|
||||
# UUID not matching, ignore.
|
||||
return
|
||||
|
||||
cast_device = _async_create_cast_device(hass, discover)
|
||||
cast_device = _async_create_cast_device(hass, config_entry, discover)
|
||||
if cast_device is not None:
|
||||
async_add_entities([cast_device])
|
||||
|
||||
@@ -179,13 +175,19 @@ class CastDevice:
|
||||
|
||||
_mz_only: bool
|
||||
|
||||
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: CastConfigEntry,
|
||||
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 = None
|
||||
self.mz_mgr: MultizoneManager | None = None
|
||||
self._status_listener: CastStatusListener | None = None
|
||||
self._add_remove_handler: Callable[[], None] | None = None
|
||||
self._del_remove_handler: Callable[[], None] | None = None
|
||||
@@ -214,7 +216,9 @@ 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.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
|
||||
self._config_entry.runtime_data.added_cast_devices.remove(
|
||||
self._cast_info.uuid
|
||||
)
|
||||
if self._add_remove_handler:
|
||||
self._add_remove_handler()
|
||||
self._add_remove_handler = None
|
||||
@@ -237,10 +241,10 @@ class CastDevice:
|
||||
)
|
||||
self._chromecast = chromecast
|
||||
|
||||
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]
|
||||
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
|
||||
|
||||
self._status_listener = CastStatusListener(
|
||||
self, chromecast, self.mz_mgr, self._mz_only
|
||||
@@ -300,10 +304,15 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_mz_only = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: CastConfigEntry,
|
||||
cast_info: ChromecastInfo,
|
||||
) -> None:
|
||||
"""Initialize the cast device."""
|
||||
|
||||
CastDevice.__init__(self, hass, cast_info)
|
||||
CastDevice.__init__(self, hass, config_entry, cast_info)
|
||||
|
||||
self.cast_status = None
|
||||
self.media_status = None
|
||||
@@ -592,7 +601,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
"""Generate root node."""
|
||||
children = []
|
||||
# Add media browsers
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
children.extend(
|
||||
await platform.async_get_media_browser_root_object(
|
||||
self.hass, self._chromecast.cast_type
|
||||
@@ -651,7 +660,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
|
||||
platform: CastProtocol
|
||||
assert media_content_type is not None
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
browse_media = await platform.async_browse_media(
|
||||
self.hass,
|
||||
media_content_type,
|
||||
@@ -713,7 +722,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity):
|
||||
return
|
||||
|
||||
# Try the cast platforms
|
||||
for platform in self.hass.data[DOMAIN]["cast_platform"].values():
|
||||
for platform in self._config_entry.runtime_data.cast_platforms.values():
|
||||
result = await platform.async_play_media(
|
||||
self.hass, self.entity_id, chromecast, media_type, media_id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,311 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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"
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""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]
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"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) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""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,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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) -> FlowResult:
|
||||
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
|
||||
"""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) -> FlowResult:
|
||||
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_timeout(self, _: None = None) -> FlowResult:
|
||||
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_abort(reason="operation_took_too_long")
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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
|
||||
@@ -0,0 +1,27 @@
|
||||
"""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()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"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 RS232 integration."""
|
||||
"""The Denon RS-232 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 RS232 from a config entry."""
|
||||
"""Set up Denon RS-232 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 RS232 integration."""
|
||||
"""Config flow for the Denon RS-232 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 RS232."""
|
||||
"""Handle a config flow for Denon RS-232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
"""Constants for the Denon RS-232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS232",
|
||||
"name": "Denon RS-232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
"""Media player platform for the Denon RS-232 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 RS232 media player."""
|
||||
"""Set up the Denon RS-232 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 RS232."""
|
||||
"""Representation of a Denon receiver controlled over RS-232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@@ -16,9 +16,11 @@ 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,
|
||||
@@ -37,15 +39,17 @@ from .const import (
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
|
||||
}
|
||||
)
|
||||
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,
|
||||
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),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -111,10 +115,13 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input:
|
||||
hostname = user_input[CONF_HOSTNAME]
|
||||
name = DEFAULT_NAME if hostname == DEFAULT_HOSTNAME else hostname
|
||||
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)
|
||||
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)
|
||||
|
||||
validate = await async_validate_hostname(
|
||||
hostname, resolver, resolver_ipv6, port, port_ipv6
|
||||
@@ -149,12 +156,6 @@ 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,6 +12,7 @@ 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,18 +9,28 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"hostname": "Hostname",
|
||||
"port": "IPv4 port",
|
||||
"port_ipv6": "IPv6 port",
|
||||
"resolver": "IPv4 resolver",
|
||||
"resolver_ipv6": "IPv6 resolver"
|
||||
"hostname": "Hostname"
|
||||
},
|
||||
"data_description": {
|
||||
"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."
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,17 +63,18 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"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%]"
|
||||
"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%]"
|
||||
},
|
||||
"data_description": {
|
||||
"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%]"
|
||||
}
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -17,13 +16,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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,22 +1,34 @@
|
||||
"""The Duco integration."""
|
||||
|
||||
from duco import DucoClient, build_ssl_context
|
||||
import re
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
|
||||
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."""
|
||||
ssl_context = await hass.async_add_executor_job(build_ssl_context)
|
||||
# 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)
|
||||
|
||||
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 import DucoClient, build_ssl_context
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -158,11 +158,9 @@ 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 import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco.models import BoardInfo, Node
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.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.exceptions import DucoConnectionError
|
||||
from duco_connectivity.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_req_remaining()
|
||||
write_remaining = await coordinator.client.async_get_write_requests_remaining()
|
||||
except DucoConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco.models import Node
|
||||
from duco_connectivity.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.exceptions import DucoError, DucoRateLimitError
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
|
||||
from duco_connectivity.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"],
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.4.1"],
|
||||
"requirements": ["python-duco-connectivity==0.2.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.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -54,20 +53,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
key="ventilation_state",
|
||||
translation_key="ventilation_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[s.lower() for s in VentilationState],
|
||||
options=[
|
||||
state.lower()
|
||||
for state in VentilationState
|
||||
if state != VentilationState.UNKNOWN
|
||||
],
|
||||
value_fn=lambda node: (
|
||||
node.ventilation.state.lower() if node.ventilation else None
|
||||
node.ventilation.state.lower()
|
||||
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
|
||||
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",
|
||||
@@ -92,17 +89,6 @@ 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,9 +47,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"box_temperature": {
|
||||
"name": "Box temperature"
|
||||
},
|
||||
"iaq_co2": {
|
||||
"name": "CO2 air quality index"
|
||||
},
|
||||
@@ -102,5 +99,10 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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,7 +2,11 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -16,14 +20,22 @@ 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 None:
|
||||
return self.async_show_form(step_id="user")
|
||||
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={},
|
||||
)
|
||||
|
||||
return self.async_create_entry(
|
||||
title="easyEnergy",
|
||||
data={},
|
||||
)
|
||||
return self.async_show_form(step_id="user", errors=errors)
|
||||
|
||||
@@ -76,7 +76,10 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
|
||||
)
|
||||
|
||||
except EasyEnergyConnectionError as err:
|
||||
raise UpdateFailed("Error communicating with easyEnergy API") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
|
||||
return EasyEnergyData(
|
||||
energy_today=energy_today,
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
{
|
||||
"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.0"],
|
||||
"requirements": ["easyenergy==3.0.1"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ 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,6 +3,9 @@
|
||||
"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%]"
|
||||
@@ -44,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"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.2.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==18.3.0"]
|
||||
}
|
||||
|
||||
@@ -72,9 +72,11 @@ PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SCENE,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""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))
|
||||
@@ -4,8 +4,7 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .manager import async_replace_device
|
||||
@@ -43,7 +42,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -52,7 +51,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_migrate(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the migrate step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -66,7 +65,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the manual step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -24,7 +25,7 @@ from homeassistant.components.device_tracker import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, 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
|
||||
@@ -226,7 +227,13 @@ 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)
|
||||
info = self.fritz_status.get_device_info()
|
||||
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
|
||||
|
||||
_LOGGER.debug(
|
||||
"gathered device info of %s %s",
|
||||
|
||||
@@ -185,6 +185,9 @@
|
||||
"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,6 +16,8 @@ from .coordinator import FritzboxDataUpdateCoordinator
|
||||
class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC):
|
||||
"""Basis FritzBox entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FritzboxDataUpdateCoordinator,
|
||||
@@ -27,11 +29,9 @@ 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.0"],
|
||||
"requirements": ["afsapi==1.0.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-frontier-silicon-com:undok:fsapi:1"
|
||||
|
||||
@@ -196,7 +196,9 @@ 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()
|
||||
(mode.label or mode.id): mode.key
|
||||
for mode in await afsapi.get_modes()
|
||||
if mode.selectable
|
||||
}
|
||||
self._attr_source_list = list(self.__modes_by_label)
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
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 (
|
||||
@@ -77,7 +76,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
return placeholders or None
|
||||
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
|
||||
"""Return form for suggestion."""
|
||||
return self.async_show_form(
|
||||
step_id=suggestion.key,
|
||||
@@ -86,7 +85,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""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:
|
||||
@@ -108,7 +107,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) -> FlowResult:
|
||||
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Show the fix menu."""
|
||||
assert self.issue
|
||||
|
||||
@@ -120,7 +119,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
async def _async_step_apply_suggestion(
|
||||
self, suggestion: Suggestion, confirmed: bool = False
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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)
|
||||
@@ -137,13 +136,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
suggestion: Suggestion,
|
||||
) -> Callable[
|
||||
[SupervisorIssueRepairFlow, dict[str, str] | None],
|
||||
Coroutine[Any, Any, FlowResult],
|
||||
Coroutine[Any, Any, RepairsFlowResult],
|
||||
]:
|
||||
"""Generate a step handler for a suggestion."""
|
||||
|
||||
async def _async_step(
|
||||
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle a flow step for a suggestion."""
|
||||
return await self._async_step_apply_suggestion(
|
||||
suggestion, confirmed=user_input is not None
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
|
||||
from pycec.const import (
|
||||
CMD_STANDBY,
|
||||
KEY_BACKWARD,
|
||||
KEY_FORWARD,
|
||||
KEY_MUTE_TOGGLE,
|
||||
@@ -93,7 +94,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycec.const import POWER_OFF, POWER_ON
|
||||
from pycec.commands import CecCommand
|
||||
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -50,7 +51,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Repairs for Home Assistant."""
|
||||
|
||||
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -18,7 +21,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -28,7 +31,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
entries = self.hass.config_entries.async_entries(self.domain)
|
||||
for entry in entries:
|
||||
@@ -37,7 +40,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_ignore(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the ignore step of a fix flow."""
|
||||
ir.async_get(self.hass).async_ignore(
|
||||
DOMAIN, f"integration_not_found.{self.domain}", True
|
||||
@@ -58,7 +61,7 @@ class OrphanedConfigEntryFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -68,14 +71,14 @@ class OrphanedConfigEntryFlow(RepairsFlow):
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
await self.hass.config_entries.async_remove(self.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_ignore(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the ignore step of a fix flow."""
|
||||
ir.async_get(self.hass).async_ignore(
|
||||
DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Provides diagnostics for the Home Assistant Connect ZBT-2 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {"config_entry": config_entry.as_dict()}
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.10.0"]
|
||||
"requirements": ["homematicip==2.11.0"]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Repairs for HomeWizard integration."""
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .config_flow import async_request_token
|
||||
|
||||
@@ -19,14 +17,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""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
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
|
||||
if user_input is not None:
|
||||
@@ -38,7 +36,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the authorize step of a fix flow."""
|
||||
|
||||
ip_address = self.entry.data[CONF_IP_ADDRESS]
|
||||
|
||||
@@ -4,8 +4,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_qube_heatpump.models import QubeState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
@@ -13,6 +11,7 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from .coordinator import QubeData
|
||||
from .entity import QubeEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -29,7 +28,7 @@ if TYPE_CHECKING:
|
||||
class QubeBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Binary sensor entity description for Qube Heat Pump."""
|
||||
|
||||
value_fn: Callable[[QubeState], bool | None]
|
||||
value_fn: Callable[[QubeData], bool | None]
|
||||
|
||||
|
||||
BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
|
||||
@@ -37,58 +36,58 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="source_pump",
|
||||
translation_key="source_pump",
|
||||
value_fn=lambda data: data.dout_srcpmp_val,
|
||||
value_fn=lambda data: data.state.dout_srcpmp_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="user_pump",
|
||||
translation_key="user_pump",
|
||||
value_fn=lambda data: data.dout_usrpmp_val,
|
||||
value_fn=lambda data: data.state.dout_usrpmp_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="four_way_valve",
|
||||
translation_key="four_way_valve",
|
||||
value_fn=lambda data: data.dout_fourwayvlv_val,
|
||||
value_fn=lambda data: data.state.dout_fourwayvlv_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="cooling_output",
|
||||
translation_key="cooling_output",
|
||||
value_fn=lambda data: data.dout_cooling_val,
|
||||
value_fn=lambda data: data.state.dout_cooling_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="three_way_valve",
|
||||
translation_key="three_way_valve",
|
||||
value_fn=lambda data: data.dout_threewayvlv_val,
|
||||
value_fn=lambda data: data.state.dout_threewayvlv_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="buffer_pump",
|
||||
translation_key="buffer_pump",
|
||||
value_fn=lambda data: data.dout_bufferpmp_val,
|
||||
value_fn=lambda data: data.state.dout_bufferpmp_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="heater_step_1",
|
||||
translation_key="heater_step_1",
|
||||
value_fn=lambda data: data.dout_heaterstep1_val,
|
||||
value_fn=lambda data: data.state.dout_heaterstep1_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="heater_step_2",
|
||||
translation_key="heater_step_2",
|
||||
value_fn=lambda data: data.dout_heaterstep2_val,
|
||||
value_fn=lambda data: data.state.dout_heaterstep2_val,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="heater_step_3",
|
||||
translation_key="heater_step_3",
|
||||
value_fn=lambda data: data.dout_heaterstep3_val,
|
||||
value_fn=lambda data: data.state.dout_heaterstep3_val,
|
||||
),
|
||||
# System status
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="keypad",
|
||||
translation_key="keypad",
|
||||
value_fn=lambda data: data.keybonoff,
|
||||
value_fn=lambda data: data.state.keybonoff,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="day_mode",
|
||||
translation_key="day_mode",
|
||||
value_fn=lambda data: data.daynightmode,
|
||||
value_fn=lambda data: data.state.daynightmode,
|
||||
),
|
||||
# Alarms
|
||||
QubeBinarySensorEntityDescription(
|
||||
@@ -96,84 +95,84 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
|
||||
translation_key="alarm_antilegionella_timeout",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.al_maxtime_antileg_active,
|
||||
value_fn=lambda data: data.state.al_maxtime_antileg_active,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_dhw_timeout",
|
||||
translation_key="alarm_dhw_timeout",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.al_maxtime_dhw_active,
|
||||
value_fn=lambda data: data.state.al_maxtime_dhw_active,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_dewpoint",
|
||||
translation_key="alarm_dewpoint",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.al_dewpoint_active,
|
||||
value_fn=lambda data: data.state.al_dewpoint_active,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_supply_too_hot",
|
||||
translation_key="alarm_supply_too_hot",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.al_underfloorsafety_active,
|
||||
value_fn=lambda data: data.state.al_underfloorsafety_active,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_flow",
|
||||
translation_key="alarm_flow",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.alrm_flw,
|
||||
value_fn=lambda data: data.state.alrm_flw,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_central_heating",
|
||||
translation_key="alarm_central_heating",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.usralrms,
|
||||
value_fn=lambda data: data.state.usralrms,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_cooling",
|
||||
translation_key="alarm_cooling",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.coolingalrms,
|
||||
value_fn=lambda data: data.state.coolingalrms,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_heating",
|
||||
translation_key="alarm_heating",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.heatingalrms,
|
||||
value_fn=lambda data: data.state.heatingalrms,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_working_hours",
|
||||
translation_key="alarm_working_hours",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.alarmmng_al_workinghour,
|
||||
value_fn=lambda data: data.state.alarmmng_al_workinghour,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_source",
|
||||
translation_key="alarm_source",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.srsalrm,
|
||||
value_fn=lambda data: data.state.srsalrm,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_global",
|
||||
translation_key="alarm_global",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.glbal,
|
||||
value_fn=lambda data: data.state.glbal,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="alarm_compressor",
|
||||
translation_key="alarm_compressor",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda data: data.alarmmng_al_pwrplus,
|
||||
value_fn=lambda data: data.state.alarmmng_al_pwrplus,
|
||||
),
|
||||
# Sensor/controller status
|
||||
QubeBinarySensorEntityDescription(
|
||||
@@ -181,76 +180,76 @@ BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = (
|
||||
translation_key="room_sensor_enabled",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.roomprb_en,
|
||||
value_fn=lambda data: data.state.roomprb_en,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="plant_sensor_enabled",
|
||||
translation_key="plant_sensor_enabled",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.plantprb_en,
|
||||
value_fn=lambda data: data.state.plantprb_en,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="buffer_sensor_enabled",
|
||||
translation_key="buffer_sensor_enabled",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.bufferprb_en,
|
||||
value_fn=lambda data: data.state.bufferprb_en,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="dhw_controller_enabled",
|
||||
translation_key="dhw_controller_enabled",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda data: data.en_dhwpid,
|
||||
value_fn=lambda data: data.state.en_dhwpid,
|
||||
),
|
||||
# Demand signals
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="plant_demand",
|
||||
translation_key="plant_demand",
|
||||
value_fn=lambda data: data.plantdemand,
|
||||
value_fn=lambda data: data.state.plantdemand,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="external_demand",
|
||||
translation_key="external_demand",
|
||||
value_fn=lambda data: data.id_demand,
|
||||
value_fn=lambda data: data.state.id_demand,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="thermostat_demand",
|
||||
translation_key="thermostat_demand",
|
||||
value_fn=lambda data: data.thermostatdemand,
|
||||
value_fn=lambda data: data.state.thermostatdemand,
|
||||
),
|
||||
# Digital inputs
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="summer_mode",
|
||||
translation_key="summer_mode",
|
||||
value_fn=lambda data: data.id_summerwinter,
|
||||
value_fn=lambda data: data.state.id_summerwinter,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="dewpoint",
|
||||
translation_key="dewpoint",
|
||||
value_fn=lambda data: data.dewpoint,
|
||||
value_fn=lambda data: data.state.dewpoint,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="booster_security",
|
||||
translation_key="booster_security",
|
||||
value_fn=lambda data: data.boostersecurity,
|
||||
value_fn=lambda data: data.state.boostersecurity,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="source_flow",
|
||||
translation_key="source_flow",
|
||||
value_fn=lambda data: data.srcflw,
|
||||
value_fn=lambda data: data.state.srcflw,
|
||||
),
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="anti_legionella",
|
||||
translation_key="anti_legionella",
|
||||
value_fn=lambda data: data.req_antileg_1,
|
||||
value_fn=lambda data: data.state.req_antileg_1,
|
||||
),
|
||||
# Energy
|
||||
QubeBinarySensorEntityDescription(
|
||||
key="pv_surplus",
|
||||
translation_key="pv_surplus",
|
||||
value_fn=lambda data: data.surplus_pv,
|
||||
value_fn=lambda data: data.state.surplus_pv,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "hr_energy_qube"
|
||||
PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR)
|
||||
PLATFORMS = (
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.WATER_HEATER,
|
||||
)
|
||||
|
||||
DEFAULT_PORT = 502
|
||||
DEFAULT_SCAN_INTERVAL = 15
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""DataUpdateCoordinator for Qube Heat Pump."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -18,7 +19,15 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QubeCoordinator(DataUpdateCoordinator[QubeState]):
|
||||
@dataclass
|
||||
class QubeData:
|
||||
"""Data from the Qube coordinator."""
|
||||
|
||||
state: QubeState
|
||||
switches: dict[str, bool | None]
|
||||
|
||||
|
||||
class QubeCoordinator(DataUpdateCoordinator[QubeData]):
|
||||
"""Qube Heat Pump data coordinator."""
|
||||
|
||||
def __init__(
|
||||
@@ -34,16 +43,17 @@ class QubeCoordinator(DataUpdateCoordinator[QubeState]):
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> QubeState:
|
||||
async def _async_update_data(self) -> QubeData:
|
||||
"""Fetch data from the device."""
|
||||
try:
|
||||
data = await self.client.get_all_data()
|
||||
state = await self.client.get_all_data()
|
||||
switches = await self.client.read_all_switches()
|
||||
except (ConnectionError, TimeoutError, OSError) as exc:
|
||||
raise UpdateFailed(
|
||||
f"Error communicating with Qube heat pump: {exc}"
|
||||
) from exc
|
||||
|
||||
if data is None:
|
||||
if state is None:
|
||||
raise UpdateFailed("No data received from Qube heat pump")
|
||||
|
||||
return data
|
||||
return QubeData(state=state, switches=switches)
|
||||
|
||||
@@ -4,8 +4,6 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from python_qube_heatpump.models import QubeState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -21,6 +19,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .coordinator import QubeData
|
||||
from .entity import QubeEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -52,12 +51,12 @@ STATUS_MAP: dict[int, str] = {
|
||||
class QubeSensorEntityDescription(SensorEntityDescription):
|
||||
"""Sensor entity description for Qube Heat Pump."""
|
||||
|
||||
value_fn: Callable[[QubeState], StateType]
|
||||
value_fn: Callable[[QubeData], StateType]
|
||||
|
||||
|
||||
def _status_value(data: QubeState) -> StateType:
|
||||
def _status_value(data: QubeData) -> StateType:
|
||||
"""Return status string from status code."""
|
||||
code = data.status_code
|
||||
code = data.state.status_code
|
||||
if code is None:
|
||||
return None
|
||||
return STATUS_MAP.get(code)
|
||||
@@ -71,7 +70,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_supply,
|
||||
value_fn=lambda data: data.state.temp_supply,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_return",
|
||||
@@ -80,7 +79,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_return,
|
||||
value_fn=lambda data: data.state.temp_return,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_source_in",
|
||||
@@ -89,7 +88,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_source_in,
|
||||
value_fn=lambda data: data.state.temp_source_in,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_source_out",
|
||||
@@ -98,7 +97,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_source_out,
|
||||
value_fn=lambda data: data.state.temp_source_out,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_room",
|
||||
@@ -107,7 +106,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_room,
|
||||
value_fn=lambda data: data.state.temp_room,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_dhw",
|
||||
@@ -116,7 +115,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_dhw,
|
||||
value_fn=lambda data: data.state.temp_dhw,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="temp_outside",
|
||||
@@ -125,7 +124,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.temp_outside,
|
||||
value_fn=lambda data: data.state.temp_outside,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="power_thermic",
|
||||
@@ -134,7 +133,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data: data.power_thermic,
|
||||
value_fn=lambda data: data.state.power_thermic,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="power_electric",
|
||||
@@ -143,7 +142,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data: data.power_electric,
|
||||
value_fn=lambda data: data.state.power_electric,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="energy_total_electric",
|
||||
@@ -152,7 +151,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=3,
|
||||
value_fn=lambda data: data.energy_total_electric,
|
||||
value_fn=lambda data: data.state.energy_total_electric,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="energy_total_thermic",
|
||||
@@ -161,14 +160,14 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=3,
|
||||
value_fn=lambda data: data.energy_total_thermic,
|
||||
value_fn=lambda data: data.state.energy_total_thermic,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="cop_calc",
|
||||
translation_key="cop_calc",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.cop_calc,
|
||||
value_fn=lambda data: data.state.cop_calc,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="compressor_speed",
|
||||
@@ -176,7 +175,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data: data.compressor_speed,
|
||||
value_fn=lambda data: data.state.compressor_speed,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="flow_rate",
|
||||
@@ -185,7 +184,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda data: data.flow_rate,
|
||||
value_fn=lambda data: data.state.flow_rate,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="setpoint_room_heat_day",
|
||||
@@ -194,7 +193,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.setpoint_room_heat_day,
|
||||
value_fn=lambda data: data.state.setpoint_room_heat_day,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="setpoint_room_heat_night",
|
||||
@@ -203,7 +202,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.setpoint_room_heat_night,
|
||||
value_fn=lambda data: data.state.setpoint_room_heat_night,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="setpoint_room_cool_day",
|
||||
@@ -212,7 +211,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.setpoint_room_cool_day,
|
||||
value_fn=lambda data: data.state.setpoint_room_cool_day,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="setpoint_room_cool_night",
|
||||
@@ -221,7 +220,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda data: data.setpoint_room_cool_night,
|
||||
value_fn=lambda data: data.state.setpoint_room_cool_night,
|
||||
),
|
||||
QubeSensorEntityDescription(
|
||||
key="status_heatpump",
|
||||
|
||||
@@ -199,6 +199,33 @@
|
||||
"temp_supply": {
|
||||
"name": "Supply temperature CH"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"anti_legionella_cycle": {
|
||||
"name": "Anti-legionella cycle"
|
||||
},
|
||||
"heating_curve": {
|
||||
"name": "Heating curve"
|
||||
},
|
||||
"heating_demand": {
|
||||
"name": "Heating demand"
|
||||
},
|
||||
"summer_mode": {
|
||||
"name": "Summer mode"
|
||||
}
|
||||
},
|
||||
"water_heater": {
|
||||
"water_heater": {
|
||||
"name": "Domestic hot water"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"set_temperature_failed": {
|
||||
"message": "Failed to set the target temperature."
|
||||
},
|
||||
"switch_command_failed": {
|
||||
"message": "Failed to send command to the heat pump."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Switch platform for Qube Heat Pump."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import QubeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import QubeCoordinator
|
||||
from .entity import QubeEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class QubeSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Switch entity description for Qube Heat Pump."""
|
||||
|
||||
register_key: str
|
||||
|
||||
|
||||
SWITCH_TYPES: tuple[QubeSwitchEntityDescription, ...] = (
|
||||
QubeSwitchEntityDescription(
|
||||
key="summer_mode",
|
||||
translation_key="summer_mode",
|
||||
register_key="bms_summerwinter",
|
||||
),
|
||||
QubeSwitchEntityDescription(
|
||||
key="anti_legionella_cycle",
|
||||
translation_key="anti_legionella_cycle",
|
||||
register_key="antilegionella_frcstart_ant",
|
||||
),
|
||||
QubeSwitchEntityDescription(
|
||||
key="heating_curve",
|
||||
translation_key="heating_curve",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
register_key="en_plantsetp_compens",
|
||||
),
|
||||
QubeSwitchEntityDescription(
|
||||
key="heating_demand",
|
||||
translation_key="heating_demand",
|
||||
register_key="modbus_demand",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: QubeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Qube switches."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
async_add_entities(
|
||||
QubeSwitch(coordinator, entry, description) for description in SWITCH_TYPES
|
||||
)
|
||||
|
||||
|
||||
class QubeSwitch(QubeEntity, SwitchEntity):
|
||||
"""Qube switch entity."""
|
||||
|
||||
entity_description: QubeSwitchEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: QubeCoordinator,
|
||||
entry: QubeConfigEntry,
|
||||
description: QubeSwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the switch."""
|
||||
super().__init__(coordinator, entry)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{entry.entry_id}-{description.key}"
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return (
|
||||
super().available
|
||||
and self.entity_description.register_key in self.coordinator.data.switches
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the switch is on."""
|
||||
return self.coordinator.data.switches.get(self.entity_description.register_key)
|
||||
|
||||
async def _async_write_switch(self, value: bool) -> None:
|
||||
"""Write switch value to the device."""
|
||||
register_key = self.entity_description.register_key
|
||||
try:
|
||||
success = await self.coordinator.client.write_switch(register_key, value)
|
||||
except (ConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_command_failed",
|
||||
) from err
|
||||
if not success:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_command_failed",
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._async_write_switch(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._async_write_switch(False)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Water heater platform for Qube Heat Pump."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
STATE_HEAT_PUMP,
|
||||
STATE_PERFORMANCE,
|
||||
WaterHeaterEntity,
|
||||
WaterHeaterEntityFeature,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import QubeConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import QubeCoordinator
|
||||
from .entity import QubeEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
DHW_BOOST_KEY = "tapw_timeprogram_bms_forced"
|
||||
DHW_SETPOINT_KEY = "setpoint_dhw"
|
||||
DHW_MIN_TEMP = 40
|
||||
DHW_MAX_TEMP = 65
|
||||
|
||||
OPERATION_MODES = [STATE_HEAT_PUMP, STATE_PERFORMANCE]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: QubeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Qube water heater."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
async_add_entities([QubeWaterHeater(coordinator, entry)])
|
||||
|
||||
|
||||
class QubeWaterHeater(QubeEntity, WaterHeaterEntity):
|
||||
"""Qube DHW water heater entity."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_min_temp = DHW_MIN_TEMP
|
||||
_attr_max_temp = DHW_MAX_TEMP
|
||||
_attr_operation_list = OPERATION_MODES
|
||||
_attr_supported_features = (
|
||||
WaterHeaterEntityFeature.TARGET_TEMPERATURE
|
||||
| WaterHeaterEntityFeature.OPERATION_MODE
|
||||
)
|
||||
_attr_translation_key = "water_heater"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: QubeCoordinator,
|
||||
entry: QubeConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the water heater."""
|
||||
super().__init__(coordinator, entry)
|
||||
self._attr_unique_id = entry.entry_id
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current DHW temperature."""
|
||||
return self.coordinator.data.state.temp_dhw
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the target DHW temperature."""
|
||||
return self.coordinator.data.state.setpoint_dhw
|
||||
|
||||
@property
|
||||
def current_operation(self) -> str | None:
|
||||
"""Return the current operation mode."""
|
||||
boost = self.coordinator.data.switches.get(DHW_BOOST_KEY)
|
||||
if boost is None:
|
||||
return None
|
||||
if boost:
|
||||
return STATE_PERFORMANCE
|
||||
return STATE_HEAT_PUMP
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set the target DHW temperature."""
|
||||
temperature = kwargs.get("temperature")
|
||||
if temperature is None:
|
||||
return
|
||||
try:
|
||||
success = await self.coordinator.client.write_setpoint(
|
||||
DHW_SETPOINT_KEY, temperature
|
||||
)
|
||||
except (ConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_temperature_failed",
|
||||
) from err
|
||||
if not success:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_temperature_failed",
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_operation_mode(self, operation_mode: str) -> None:
|
||||
"""Set the operation mode."""
|
||||
boost = operation_mode == STATE_PERFORMANCE
|
||||
try:
|
||||
success = await self.coordinator.client.write_switch(DHW_BOOST_KEY, boost)
|
||||
except (ConnectionError, TimeoutError, OSError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_command_failed",
|
||||
) from err
|
||||
if not success:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="switch_command_failed",
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
import socket
|
||||
import ssl
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.abc import AbstractStreamWriter
|
||||
@@ -37,7 +37,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.http import (
|
||||
KEY_ALLOW_CONFIGURED_CORS,
|
||||
@@ -60,7 +60,29 @@ from homeassistant.util.json import json_loads
|
||||
|
||||
from .auth import async_setup_auth
|
||||
from .ban import setup_bans
|
||||
from .const import DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER # noqa: F401
|
||||
from .config import async_load_config
|
||||
from .const import ( # noqa: F401
|
||||
CONF_BASE_URL,
|
||||
CONF_CORS_ORIGINS,
|
||||
CONF_IP_BAN_ENABLED,
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD,
|
||||
CONF_SERVER_HOST,
|
||||
CONF_SERVER_PORT,
|
||||
CONF_SSL_CERTIFICATE,
|
||||
CONF_SSL_KEY,
|
||||
CONF_SSL_PEER_CERTIFICATE,
|
||||
CONF_SSL_PROFILE,
|
||||
CONF_TRUSTED_PROXIES,
|
||||
CONF_USE_X_FORWARDED_FOR,
|
||||
CONF_USE_X_FRAME_OPTIONS,
|
||||
DEFAULT_CORS,
|
||||
DOMAIN,
|
||||
KEY_HASS_REFRESH_TOKEN_ID,
|
||||
KEY_HASS_USER,
|
||||
NO_LOGIN_ATTEMPT_THRESHOLD,
|
||||
SSL_INTERMEDIATE,
|
||||
SSL_MODERN,
|
||||
)
|
||||
from .cors import setup_cors
|
||||
from .decorators import require_admin # noqa: F401
|
||||
from .forwarded import async_setup_forwarded
|
||||
@@ -70,38 +92,13 @@ from .security_filter import setup_security_filter
|
||||
from .static import CACHE_HEADERS, CachingStaticResource
|
||||
from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite
|
||||
|
||||
CONF_SERVER_HOST: Final = "server_host"
|
||||
CONF_SERVER_PORT: Final = "server_port"
|
||||
CONF_BASE_URL: Final = "base_url"
|
||||
CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
|
||||
CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
|
||||
CONF_SSL_KEY: Final = "ssl_key"
|
||||
CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
|
||||
CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
|
||||
CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
|
||||
CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
|
||||
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
|
||||
CONF_SSL_PROFILE: Final = "ssl_profile"
|
||||
|
||||
SSL_MODERN: Final = "modern"
|
||||
SSL_INTERMEDIATE: Final = "intermediate"
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DEVELOPMENT: Final = "0"
|
||||
# Cast to be able to load custom cards.
|
||||
# My to be able to check url and version info.
|
||||
DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
|
||||
NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
|
||||
|
||||
MAX_CLIENT_SIZE: Final = 1024**2 * 16
|
||||
MAX_LINE_SIZE: Final = 24570
|
||||
|
||||
STORAGE_KEY: Final = DOMAIN
|
||||
STORAGE_VERSION: Final = 1
|
||||
SAVE_DELAY: Final = 180
|
||||
|
||||
_HAS_IPV6 = hasattr(socket, "AF_INET6")
|
||||
_DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"]
|
||||
|
||||
@@ -154,30 +151,6 @@ _STATIC_CLASSES = {
|
||||
}
|
||||
|
||||
|
||||
class ConfData(TypedDict, total=False):
|
||||
"""Typed dict for config data."""
|
||||
|
||||
server_host: list[str]
|
||||
server_port: int
|
||||
base_url: str
|
||||
ssl_certificate: str
|
||||
ssl_peer_certificate: str
|
||||
ssl_key: str
|
||||
cors_allowed_origins: list[str]
|
||||
use_x_forwarded_for: bool
|
||||
use_x_frame_options: bool
|
||||
trusted_proxies: list[IPv4Network | IPv6Network]
|
||||
login_attempts_threshold: int
|
||||
ip_ban_enabled: bool
|
||||
ssl_profile: str
|
||||
|
||||
|
||||
async def async_get_last_config(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
"""Return the last known working config."""
|
||||
store = storage.Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
return await store.async_load()
|
||||
|
||||
|
||||
class ApiConfig:
|
||||
"""Configuration settings for API server."""
|
||||
|
||||
@@ -201,10 +174,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# we import aiohttp_fast_zlib
|
||||
(await async_import_module(hass, "aiohttp_fast_zlib")).enable()
|
||||
|
||||
conf: ConfData | None = config.get(DOMAIN)
|
||||
# Deferred import: websocket_api declares http as its manifest
|
||||
# dependency and imports back into this package at module load
|
||||
# (websocket_api/http.py -> homeassistant.components.http). A top-level
|
||||
# import of .websocket_api here would re-enter the still-loading
|
||||
# websocket_api package and fail when applying its decorators
|
||||
# (e.g. @websocket_api.require_admin).
|
||||
websocket_api_module = await async_import_module(
|
||||
hass, "homeassistant.components.http.websocket_api"
|
||||
)
|
||||
|
||||
if conf is None:
|
||||
conf = cast(ConfData, HTTP_SCHEMA({}))
|
||||
conf = await async_load_config(hass, config)
|
||||
|
||||
websocket_api_module.async_register_websocket_commands(hass)
|
||||
|
||||
if CONF_SERVER_HOST in conf and is_hassio(hass):
|
||||
issue_id = "server_host_deprecated_hassio"
|
||||
@@ -271,9 +253,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Start the server."""
|
||||
with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP):
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
||||
# We already checked it's not None.
|
||||
assert conf is not None
|
||||
await start_http_server_and_save_config(hass, dict(conf), server)
|
||||
await server.start()
|
||||
|
||||
async_when_setup_or_start(hass, "frontend", start_server)
|
||||
|
||||
@@ -709,23 +689,3 @@ class HomeAssistantHTTP:
|
||||
await self.site.stop()
|
||||
if self.runner is not None:
|
||||
await self.runner.cleanup()
|
||||
|
||||
|
||||
async def start_http_server_and_save_config(
|
||||
hass: HomeAssistant, conf: dict, server: HomeAssistantHTTP
|
||||
) -> None:
|
||||
"""Startup the http server and save the config."""
|
||||
await server.start()
|
||||
|
||||
# If we are set up successful, we store the HTTP settings for recovery mode.
|
||||
store: storage.Store[dict[str, Any]] = storage.Store(
|
||||
hass, STORAGE_VERSION, STORAGE_KEY
|
||||
)
|
||||
|
||||
if CONF_TRUSTED_PROXIES in conf:
|
||||
conf[CONF_TRUSTED_PROXIES] = [
|
||||
str(cast(IPv4Network | IPv6Network, ip).network_address)
|
||||
for ip in conf[CONF_TRUSTED_PROXIES]
|
||||
]
|
||||
|
||||
store.async_delay_save(lambda: conf, SAVE_DELAY)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""User-managed HTTP configuration store."""
|
||||
|
||||
import asyncio
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
import logging
|
||||
from typing import Any, Final, TypedDict, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import SERVER_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_CORS_ORIGINS,
|
||||
CONF_IP_BAN_ENABLED,
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD,
|
||||
CONF_SERVER_HOST,
|
||||
CONF_SERVER_PORT,
|
||||
CONF_SSL_CERTIFICATE,
|
||||
CONF_SSL_KEY,
|
||||
CONF_SSL_PEER_CERTIFICATE,
|
||||
CONF_SSL_PROFILE,
|
||||
CONF_TRUSTED_PROXIES,
|
||||
CONF_USE_X_FORWARDED_FOR,
|
||||
CONF_USE_X_FRAME_OPTIONS,
|
||||
DEFAULT_CORS,
|
||||
DOMAIN,
|
||||
NO_LOGIN_ATTEMPT_THRESHOLD,
|
||||
SSL_INTERMEDIATE,
|
||||
SSL_MODERN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_KEY: Final = DOMAIN
|
||||
STORAGE_VERSION: Final = 2
|
||||
|
||||
KEY_STABLE: Final = "stable"
|
||||
KEY_PENDING: Final = "pending"
|
||||
KEY_YAML_MIGRATION_DONE: Final = "yaml_migration_done"
|
||||
|
||||
DATA_STORE: HassKey[HTTPConfigStore] = HassKey(STORAGE_KEY)
|
||||
|
||||
|
||||
class ConfData(TypedDict, total=False):
|
||||
"""Typed dict for the validated HTTP config (matches ``HTTP_STORAGE_SCHEMA``)."""
|
||||
|
||||
server_host: list[str]
|
||||
server_port: int
|
||||
ssl_certificate: str
|
||||
ssl_peer_certificate: str
|
||||
ssl_key: str
|
||||
cors_allowed_origins: list[str]
|
||||
use_x_forwarded_for: bool
|
||||
trusted_proxies: list[IPv4Network | IPv6Network]
|
||||
login_attempts_threshold: int
|
||||
ip_ban_enabled: bool
|
||||
ssl_profile: str
|
||||
use_x_frame_options: bool
|
||||
|
||||
|
||||
class _HTTPStoreData(TypedDict):
|
||||
"""Data structure for HTTP config storage."""
|
||||
|
||||
stable: ConfData
|
||||
pending: ConfData | None
|
||||
yaml_migration_done: bool
|
||||
|
||||
|
||||
def _ip_network_str(value: Any) -> str:
|
||||
"""Validate the value is a valid IP network and return its string form."""
|
||||
return str(ip_network(value))
|
||||
|
||||
|
||||
HTTP_STORAGE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
# YAML used to allow base_url (deprecated); strip it on the way in so
|
||||
# the stored config never contains it.
|
||||
vol.Remove(CONF_BASE_URL): object,
|
||||
vol.Optional(CONF_SERVER_HOST): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
|
||||
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
|
||||
vol.Optional(CONF_SSL_KEY): cv.isfile,
|
||||
vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean,
|
||||
vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All(
|
||||
cv.ensure_list, [_ip_network_str]
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
|
||||
): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),
|
||||
vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean,
|
||||
vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
|
||||
[SSL_INTERMEDIATE, SSL_MODERN]
|
||||
),
|
||||
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
_DEFAULT_CONFIG: Final[ConfData] = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
|
||||
|
||||
|
||||
async def async_load_config(hass: HomeAssistant, config: ConfigType) -> ConfData:
|
||||
"""Load the HTTP config to apply on this startup.
|
||||
|
||||
YAML config is only migrated once. Subsequent boots will ignore YAML and
|
||||
use the store exclusively.
|
||||
|
||||
Resolution order:
|
||||
- Recovery mode: always use ``stable`` so HA stays reachable after a bad
|
||||
config; YAML is ignored entirely (any pending YAML migration is
|
||||
deferred to the next normal boot).
|
||||
- Normal mode: prefer ``pending`` if set, otherwise ``stable``.
|
||||
"""
|
||||
store = await async_get_and_load_store(hass)
|
||||
if hass.config.recovery_mode:
|
||||
_LOGGER.info("Recovery mode active; using stable HTTP config")
|
||||
return store.stable
|
||||
|
||||
yaml_conf: ConfData | None = config.get(DOMAIN)
|
||||
if store.yaml_migration_done:
|
||||
if yaml_conf is not None:
|
||||
# YAML is still present after migration completed; surface a repair
|
||||
# issue so the user knows their YAML is being ignored.
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"yaml_still_present_after_migration",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="yaml_still_present_after_migration",
|
||||
)
|
||||
else:
|
||||
# Clear any leftover deprecation issues if YAML was removed after migration.
|
||||
ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml_import_error")
|
||||
ir.async_delete_issue(hass, DOMAIN, "deprecated_yaml")
|
||||
ir.async_delete_issue(hass, DOMAIN, "yaml_still_present_after_migration")
|
||||
else:
|
||||
# Migrate YAML to storage and use it directly for this start. The
|
||||
# migration function also marks the migration as done so future
|
||||
# starts will ignore any remaining YAML.
|
||||
conf_in_yaml = yaml_conf is not None
|
||||
if yaml_conf is None:
|
||||
yaml_conf = cast(ConfData, HTTP_STORAGE_SCHEMA({}))
|
||||
|
||||
try:
|
||||
await store.async_migrate_yaml(yaml_conf)
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to migrate HTTP YAML configuration to storage")
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_error",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="deprecated_yaml_import_error",
|
||||
)
|
||||
else:
|
||||
if conf_in_yaml:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml",
|
||||
breaks_in_ha_version="2027.6.0",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
)
|
||||
|
||||
if store.pending is not None:
|
||||
_LOGGER.info("Using pending HTTP config")
|
||||
return store.pending
|
||||
|
||||
_LOGGER.info("Using stable HTTP config")
|
||||
return store.stable
|
||||
|
||||
|
||||
async def async_get_and_load_store(hass: HomeAssistant) -> HTTPConfigStore:
|
||||
"""Return the singleton HTTP config store and load it."""
|
||||
if (store := hass.data.get(DATA_STORE)) is None:
|
||||
store = HTTPConfigStore(hass)
|
||||
hass.data[DATA_STORE] = store
|
||||
await store.async_load()
|
||||
return store
|
||||
|
||||
|
||||
class HTTPConfigStore:
|
||||
"""Persist HTTP config as a stable/pending pair.
|
||||
|
||||
``stable`` holds the last config the user confirmed as working;
|
||||
``pending`` holds an unconfirmed config the user wants to try on
|
||||
the next start. Normal startup prefers ``pending`` so the new
|
||||
config gets exercised; recovery mode falls back to ``stable`` so
|
||||
Home Assistant can still come up after a bad config.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the store."""
|
||||
self._hass = hass
|
||||
self._store = _HTTPStore(
|
||||
hass,
|
||||
STORAGE_VERSION,
|
||||
STORAGE_KEY,
|
||||
private=True,
|
||||
atomic_writes=True,
|
||||
)
|
||||
self._stable: ConfData = _DEFAULT_CONFIG
|
||||
self._pending: ConfData | None = None
|
||||
self._yaml_migration_done = False
|
||||
self._loaded = False
|
||||
self._load_lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def stable(self) -> ConfData:
|
||||
"""Return the last confirmed-working config."""
|
||||
return self._stable
|
||||
|
||||
@property
|
||||
def pending(self) -> ConfData | None:
|
||||
"""Return the unconfirmed config awaiting promotion, if any."""
|
||||
return self._pending
|
||||
|
||||
@property
|
||||
def yaml_migration_done(self) -> bool:
|
||||
"""Return whether the YAML migration has been completed."""
|
||||
return self._yaml_migration_done
|
||||
|
||||
async def async_load(self) -> None:
|
||||
"""Load the stable and pending configs from disk."""
|
||||
if self._loaded:
|
||||
return
|
||||
async with self._load_lock:
|
||||
if self._loaded:
|
||||
# Another coroutine may have loaded the config while we were waiting
|
||||
# for the lock; check again to avoid unnecessary disk I/O.
|
||||
return # type: ignore[unreachable]
|
||||
raw = await self._store.async_load()
|
||||
if raw is not None:
|
||||
self._stable = raw[KEY_STABLE]
|
||||
self._pending = raw[KEY_PENDING]
|
||||
self._yaml_migration_done = raw[KEY_YAML_MIGRATION_DONE]
|
||||
self._loaded = True
|
||||
|
||||
async def async_set_pending(self, config: ConfData | None) -> None:
|
||||
"""Set (or clear) the pending config."""
|
||||
await self.async_load()
|
||||
if config == self.stable:
|
||||
# No need to save a pending config that is the same as stable.
|
||||
config = None
|
||||
self._pending = config
|
||||
await self._async_persist()
|
||||
|
||||
async def async_promote_pending(self) -> None:
|
||||
"""Promote the pending config to stable.
|
||||
|
||||
Raises ``HomeAssistantError`` if there is nothing to promote.
|
||||
"""
|
||||
await self.async_load()
|
||||
if self._pending is None:
|
||||
raise HomeAssistantError("No pending HTTP config to promote")
|
||||
self._stable = self._pending
|
||||
self._pending = None
|
||||
await self._async_persist()
|
||||
|
||||
async def async_migrate_yaml(self, config: ConfData) -> None:
|
||||
"""Migrate YAML config to storage as pending if not the same as the config used for recovery."""
|
||||
await self.async_load()
|
||||
validated_config = cast(ConfData, HTTP_STORAGE_SCHEMA(config))
|
||||
self._pending = None if validated_config == self._stable else validated_config
|
||||
self._yaml_migration_done = True
|
||||
await self._async_persist()
|
||||
|
||||
async def _async_persist(self) -> None:
|
||||
"""Write the current state to disk (or remove the file if empty)."""
|
||||
await self._store.async_save(
|
||||
{
|
||||
KEY_STABLE: self._stable,
|
||||
KEY_PENDING: self._pending,
|
||||
KEY_YAML_MIGRATION_DONE: self._yaml_migration_done,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _HTTPStore(Store[_HTTPStoreData]):
|
||||
"""Http store."""
|
||||
|
||||
async def _async_migrate_func(
|
||||
self,
|
||||
old_major_version: int,
|
||||
old_minor_version: int,
|
||||
old_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if old_major_version == 1:
|
||||
# Run the v1 payload through the storage schema so the v2 ``stable``
|
||||
# slot is well-formed (all keys present, values normalised) and the
|
||||
# load step can rely on direct key access.
|
||||
try:
|
||||
stable = HTTP_STORAGE_SCHEMA(old_data)
|
||||
except vol.Invalid:
|
||||
_LOGGER.warning(
|
||||
"Discarding invalid v1 HTTP config during migration; "
|
||||
"falling back to defaults"
|
||||
)
|
||||
stable = HTTP_STORAGE_SCHEMA({})
|
||||
return {
|
||||
KEY_STABLE: stable,
|
||||
KEY_PENDING: None,
|
||||
KEY_YAML_MIGRATION_DONE: False,
|
||||
}
|
||||
return old_data
|
||||
@@ -11,6 +11,30 @@ DOMAIN: Final = "http"
|
||||
KEY_HASS_USER: Final = "hass_user"
|
||||
KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id"
|
||||
|
||||
CONF_SERVER_HOST: Final = "server_host"
|
||||
CONF_SERVER_PORT: Final = "server_port"
|
||||
CONF_BASE_URL: Final = "base_url"
|
||||
CONF_SSL_CERTIFICATE: Final = "ssl_certificate"
|
||||
CONF_SSL_PEER_CERTIFICATE: Final = "ssl_peer_certificate"
|
||||
CONF_SSL_KEY: Final = "ssl_key"
|
||||
CONF_CORS_ORIGINS: Final = "cors_allowed_origins"
|
||||
CONF_USE_X_FORWARDED_FOR: Final = "use_x_forwarded_for"
|
||||
CONF_USE_X_FRAME_OPTIONS: Final = "use_x_frame_options"
|
||||
CONF_TRUSTED_PROXIES: Final = "trusted_proxies"
|
||||
CONF_LOGIN_ATTEMPTS_THRESHOLD: Final = "login_attempts_threshold"
|
||||
CONF_IP_BAN_ENABLED: Final = "ip_ban_enabled"
|
||||
CONF_SSL_PROFILE: Final = "ssl_profile"
|
||||
|
||||
SSL_MODERN: Final = "modern"
|
||||
SSL_INTERMEDIATE: Final = "intermediate"
|
||||
|
||||
# Cast to be able to load custom cards.
|
||||
# My to be able to check url and version info.
|
||||
DEFAULT_CORS: Final[list[str]] = ["https://cast.home-assistant.io"]
|
||||
NO_LOGIN_ATTEMPT_THRESHOLD: Final = -1
|
||||
|
||||
ATTR_CONFIG = "config"
|
||||
|
||||
|
||||
def is_supervisor_unix_socket_request(request: Request) -> bool:
|
||||
"""Check if request arrived over the Supervisor Unix socket."""
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "Your existing HTTP configuration from `configuration.yaml` has been imported. The `http` integration is now configured from the UI under **Settings** > **System** > **Network**.\n\nPlease remove the `http:` block from your `configuration.yaml` and restart Home Assistant.",
|
||||
"title": "The HTTP YAML configuration is deprecated"
|
||||
},
|
||||
"deprecated_yaml_import_error": {
|
||||
"description": "Migrating the `http` configuration from `configuration.yaml` to the integration's storage failed. Please check the logs for details and configure the `http` integration from the UI under **Settings** > **System** > **Network**.",
|
||||
"title": "Failed to import HTTP YAML configuration"
|
||||
},
|
||||
"server_host_deprecated_hassio": {
|
||||
"description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
|
||||
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
|
||||
@@ -7,6 +15,10 @@
|
||||
"ssl_configured_without_configured_urls": {
|
||||
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
|
||||
"title": "SSL is configured without an external URL or internal URL"
|
||||
},
|
||||
"yaml_still_present_after_migration": {
|
||||
"description": "The HTTP configuration in `configuration.yaml` has already been migrated and is now being ignored. Please remove the `http:` block from your `configuration.yaml`. Manage the HTTP configuration from the UI under **Settings** > **System** > **Network**.",
|
||||
"title": "HTTP YAML configuration is ignored after migration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""WebSocket API for the HTTP integration user config."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .config import HTTP_STORAGE_SCHEMA, async_get_and_load_store
|
||||
from .const import ATTR_CONFIG
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_commands(hass: HomeAssistant) -> None:
|
||||
"""Register the HTTP config websocket commands."""
|
||||
websocket_api.async_register_command(hass, websocket_get_config)
|
||||
websocket_api.async_register_command(hass, websocket_set_config)
|
||||
websocket_api.async_register_command(hass, websocket_promote_config)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "http/config"})
|
||||
@websocket_api.async_response
|
||||
async def websocket_get_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the active HTTP configuration (the confirmed-working ``stable`` slot)."""
|
||||
store = await async_get_and_load_store(hass)
|
||||
connection.send_result(msg["id"], store.stable)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "http/config/configure",
|
||||
vol.Required(ATTR_CONFIG): vol.Any(None, HTTP_STORAGE_SCHEMA),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_set_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Store a new pending HTTP configuration.
|
||||
|
||||
The new config is not applied until Home Assistant is restarted
|
||||
and the user promotes it via ``http/config/promote``. Until then
|
||||
the existing ``stable`` config remains the recovery fallback.
|
||||
"""
|
||||
store = await async_get_and_load_store(hass)
|
||||
await store.async_set_pending(msg[ATTR_CONFIG])
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "http/config/promote"})
|
||||
@websocket_api.async_response
|
||||
async def websocket_promote_config(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Promote the pending HTTP config to stable.
|
||||
|
||||
Called by the user after they have verified Home Assistant is
|
||||
working correctly with the pending config. The stable config is
|
||||
the one used by recovery mode, so promotion must be explicit.
|
||||
"""
|
||||
store = await async_get_and_load_store(hass)
|
||||
try:
|
||||
await store.async_promote_pending()
|
||||
except HomeAssistantError as err:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_ALLOWED,
|
||||
str(err),
|
||||
)
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
@@ -46,6 +46,11 @@ async def async_setup_entry(
|
||||
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
|
||||
hass, config_entry, hydrawise, main_coordinator
|
||||
)
|
||||
# async_track_zones is registered first on water_use_coordinator,
|
||||
# so the water-use coordinator's data is in sync before
|
||||
# callbacks below construct entities for newly added zones.
|
||||
water_use_coordinator.async_track_zones()
|
||||
main_coordinator.async_track_zones()
|
||||
await water_use_coordinator.async_config_entry_first_refresh()
|
||||
config_entry.runtime_data = HydrawiseUpdateCoordinators(
|
||||
main=main_coordinator,
|
||||
|
||||
@@ -82,6 +82,10 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
self.new_zones_callbacks: list[
|
||||
Callable[[Iterable[tuple[Zone, Controller]]], None]
|
||||
] = []
|
||||
|
||||
@callback
|
||||
def async_track_zones(self) -> None:
|
||||
"""Begin tracking zone and controller add/remove on updates."""
|
||||
self.async_add_listener(self._add_remove_zones)
|
||||
|
||||
async def _async_update_data(self) -> HydrawiseData:
|
||||
@@ -198,6 +202,23 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
|
||||
self.api = api
|
||||
self._main_coordinator = main_coordinator
|
||||
|
||||
@callback
|
||||
def async_track_zones(self) -> None:
|
||||
"""Begin tracking zone and controller add/remove on updates."""
|
||||
self._main_coordinator.async_add_listener(self._sync_data_from_main)
|
||||
|
||||
@callback
|
||||
def _sync_data_from_main(self) -> None:
|
||||
"""Sync data references from the main coordinator after it updates."""
|
||||
if self.data is None or self._main_coordinator.data is None:
|
||||
return # type: ignore[unreachable]
|
||||
main_data = self._main_coordinator.data
|
||||
self.data.user = main_data.user
|
||||
self.data.controllers = main_data.controllers
|
||||
self.data.zones = main_data.zones
|
||||
self.data.zone_id_to_controller = main_data.zone_id_to_controller
|
||||
self.data.sensors = main_data.sensors
|
||||
|
||||
async def _async_update_data(self) -> HydrawiseData:
|
||||
"""Fetch the latest data from Hydrawise."""
|
||||
daily_water_summary: dict[int, ControllerWaterUseSummary] = {}
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "iaqualink"
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
|
||||
"iaqua": timedelta(seconds=15),
|
||||
"exo": timedelta(seconds=60),
|
||||
}
|
||||
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user