Compare commits

..

7 Commits

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

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