Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 37f5905245 Remove location_name property from mobile_app device tracker 2026-05-04 17:00:57 +02:00
953 changed files with 8376 additions and 48607 deletions
+3 -3
View File
@@ -108,7 +108,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -119,7 +119,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package
@@ -323,7 +323,7 @@ jobs:
exclude-list: '["odroid-xu","qemuarm","qemux86","raspberrypi","raspberrypi2","raspberrypi3","raspberrypi4","tinker"]'
publish_container:
name: Publish to ${{ matrix.registry }}
name: Publish meta container for ${{ matrix.registry }}
environment: ${{ needs.init.outputs.channel }}
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
+2 -2
View File
@@ -281,7 +281,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -302,7 +302,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
with:
extra-args: --all-files zizmor
+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"
-5
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.*
@@ -446,7 +442,6 @@ homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.*
homeassistant.components.proximity.*
homeassistant.components.prusalink.*
homeassistant.components.ptdevices.*
homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
Generated
+4 -17
View File
@@ -196,7 +196,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/tests/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/aws_s3/ @tomasbedrich
@@ -289,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
@@ -350,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
@@ -858,8 +851,8 @@ CLAUDE.md @home-assistant/core
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1 @ssyrell
/tests/components/insteon/ @teharris1 @ssyrell
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
@@ -1099,8 +1092,6 @@ CLAUDE.md @home-assistant/core
/tests/components/minecraft_server/ @elmurato @zachdeibert
/homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan
/homeassistant/components/mitsubishi_comfort/ @nikolairahimi
/tests/components/mitsubishi_comfort/ @nikolairahimi
/homeassistant/components/moat/ @bdraco
/tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core
@@ -1315,8 +1306,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
@@ -1389,8 +1378,6 @@ CLAUDE.md @home-assistant/core
/tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978
/tests/components/ptdevices/ @ParemTech-Inc @frogman85978
/homeassistant/components/pterodactyl/ @elmurato
/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
@@ -1504,8 +1491,8 @@ CLAUDE.md @home-assistant/core
/tests/components/roku/ @ctalkington
/homeassistant/components/romy/ @xeniter
/tests/components/romy/ @xeniter
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn
/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
"integrations": ["melcloud", "mitsubishi_comfort"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "sensereo",
"name": "Sensereo",
"iot_standards": ["matter"]
}
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "zunzunbee",
"name": "Zunzunbee",
"iot_standards": ["zigbee"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.4.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
+1 -10
View File
@@ -17,13 +17,7 @@
"longitude": "[%key:common::config_flow::data::longitude%]",
"radius": "Station radius (miles; optional)"
},
"data_description": {
"api_key": "To generate an API key, go to {api_key_url}.",
"latitude": "The latitude of your location.",
"longitude": "The longitude of your location.",
"radius": "The radius in miles around your location to search for reporting stations."
},
"description": "To generate an API key, go to {api_key_url}."
"description": "To generate API key go to {api_key_url}"
}
}
},
@@ -46,9 +40,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),
@@ -899,13 +899,12 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
async def async_will_remove_from_hass(self) -> None:
"""Remove listeners when removing automation from Home Assistant."""
await super().async_will_remove_from_hass()
await self._async_disable()
if self.registry_entry and self.registry_entry.entity_id != self.entity_id:
# Entity ID change, do not unload the script or conditions as they will
# be reused.
await self._async_disable()
return
await self._async_disable(stop_actions=False)
await self.action_script.async_unload()
self.action_script.async_unload()
if self._condition is not None:
self._condition.async_unload()
+1 -33
View File
@@ -1,33 +1 @@
"""The Avea integration."""
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
)
entry.runtime_data = avea.Bulb(ble_device)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Unload an Avea config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
"""The avea component."""
@@ -1,216 +0,0 @@
"""Config flow for Avea."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from .const import AVEA_SERVICE_UUID, DOMAIN, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _validate_device(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Validate the device is reachable and return a title for it."""
bulb = avea.Bulb(discovery_info.device)
try:
if not bulb.connect():
raise CannotConnect
try:
name = bulb.get_name()
except BleakError, OSError, RuntimeError:
_LOGGER.debug(
"Failed to get name for Avea device %s",
discovery_info.address,
exc_info=True,
)
name = None
brightness = bulb.get_brightness()
except (BleakError, OSError, RuntimeError) as err:
raise CannotConnect from err
finally:
with suppress(BleakError, OSError, RuntimeError):
bulb.close()
if brightness is None:
raise CannotConnect
return (
_normalize_name(name)
or _normalize_name(discovery_info.name)
or discovery_info.address
)
def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
"""Return if the bluetooth discovery matches an Avea bulb."""
return AVEA_SERVICE_UUID in discovery_info.service_uuids
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": discovery_info.name or discovery_info.address
}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the discovered device before creating the entry."""
assert self._discovery_info is not None
errors: dict[str, str] = {}
if user_input is not None:
try:
title = await self.hass.async_add_executor_job(
_validate_device, self._discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: self._discovery_info.address},
)
self.context["title_placeholders"] = {
"name": self._discovery_info.name or self._discovery_info.address
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=self.context["title_placeholders"],
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
try:
title = await self.hass.async_add_executor_job(
_validate_device, discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: address},
)
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not _is_avea_discovery(discovery)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
if self._discovery_info:
data_schema = vol.Schema(
{
vol.Required(
CONF_ADDRESS, default=self._discovery_info.address
): vol.In(
{
self._discovery_info.address: (
f"{self._discovery_info.name or self._discovery_info.address}"
f" ({self._discovery_info.address})"
)
}
)
}
)
else:
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
address = import_data[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=import_data.get(CONF_NAME, address),
data={CONF_ADDRESS: address},
)
class CannotConnect(Exception):
"""Error to indicate an Avea device cannot be connected to."""
-8
View File
@@ -1,8 +0,0 @@
"""Constants for the Avea integration."""
DOMAIN = "avea"
INTEGRATION_TITLE = "Elgato Avea"
MANUFACTURER = "Elgato"
MODEL = "Avea"
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
UNKNOWN_NAME = "Unknown"
+18 -142
View File
@@ -1,11 +1,8 @@
"""Light platform for Avea."""
"""Support for the Elgato Avea lights."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -13,153 +10,29 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import AveaConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
def _create_yaml_import_failed_issue(hass: HomeAssistant) -> None:
"""Create a repair issue when the Avea YAML import cannot find bulbs."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_no_bulbs",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_no_bulbs",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AveaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Avea light platform."""
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
def _discover_bulbs_for_import() -> list[dict[str, str]]:
"""Discover and validate Avea bulbs for YAML import."""
discovered_bulbs: list[dict[str, str]] = []
for bulb in avea.discover_avea_bulbs():
address = bulb.addr
try:
name = bulb.get_name()
brightness = bulb.get_brightness()
except UPDATE_EXCEPTIONS as err:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: %s",
address,
err,
)
continue
finally:
with suppress(*UPDATE_EXCEPTIONS):
bulb.close()
if brightness is None:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
address,
)
continue
discovered_bulbs.append(
{
CONF_ADDRESS: address,
CONF_NAME: _normalize_name(name)
or _normalize_name(bulb.name)
or address,
}
)
return discovered_bulbs
async def async_setup_platform(
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Import the Avea YAML platform into config entries."""
"""Set up the Avea platform."""
try:
bulbs = await hass.async_add_executor_job(_discover_bulbs_for_import)
except UPDATE_EXCEPTIONS as err:
raise PlatformNotReady("Could not discover Avea bulbs for YAML import") from err
nearby_bulbs = avea.discover_avea_bulbs()
for bulb in nearby_bulbs:
bulb.get_name()
bulb.get_brightness()
except OSError as err:
raise PlatformNotReady from err
if not bulbs:
_create_yaml_import_failed_issue(hass)
for bulb in bulbs:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=bulb,
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
_LOGGER.warning(
"Skipping Avea YAML import for bulb %s: %s",
bulb[CONF_ADDRESS],
result.get("reason"),
)
continue
_create_deprecated_yaml_issue(hass)
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
class AveaLight(LightEntity):
@@ -168,7 +41,7 @@ class AveaLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb) -> None:
def __init__(self, light):
"""Initialize an AveaLight."""
self._light = light
self._attr_name = light.name
@@ -191,7 +64,10 @@ class AveaLight(LightEntity):
self._light.set_brightness(0)
def update(self) -> None:
"""Fetch new state data for this light."""
"""Fetch new state data for this light.
This is the only method that should fetch new data for Home Assistant.
"""
if (brightness := self._light.get_brightness()) is not None:
self._attr_is_on = brightness != 0
self._attr_brightness = round(255 * (brightness / 4095))
+1 -9
View File
@@ -1,18 +1,10 @@
{
"domain": "avea",
"name": "Elgato Avea",
"bluetooth": [
{
"local_name": "Avea*",
"service_uuid": "f815e810-456c-6761-746f-4d756e696368"
}
],
"codeowners": ["@pattyland"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/avea",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["avea"],
"quality_scale": "legacy",
"requirements": ["avea==1.6.1"]
}
@@ -1,35 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
},
"deprecated_yaml_import_issue_no_bulbs": {
"description": "Configuring {integration_title} using YAML is deprecated and will be removed in a future release. While importing your YAML configuration, Home Assistant could not discover any Avea bulbs. Make sure the bulbs are powered on, nearby, and reachable over Bluetooth, then restart Home Assistant. If you no longer use the YAML configuration, remove the `{domain}` entry from your `configuration.yaml` file.",
"title": "Avea YAML configuration import failed"
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==71"],
"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."]
}
+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)
+23 -3
View File
@@ -1,16 +1,36 @@
"""Provides triggers for buttons."""
from homeassistant.core import HomeAssistant
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
from . import DOMAIN
class ButtonPressedTrigger(StatelessEntityTriggerBase):
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the button is pressed
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not invalid."""
return state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
TRIGGERS: dict[str, type[Trigger]] = {
+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"
}
}
}
}
}
}
}
+5 -23
View File
@@ -13,8 +13,8 @@ from homeassistant.helpers.condition import (
Condition,
ConditionConfig,
EntityConditionBase,
EntityNumericalConditionBase,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -59,33 +59,12 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, entity_state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetHumidityCondition(EntityNumericalConditionBase):
"""Condition for climate target humidity."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -109,7 +88,10 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": ClimateTargetHumidityCondition,
"target_humidity": make_entity_numerical_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}
+10 -38
View File
@@ -8,15 +8,14 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerBase,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
@@ -56,13 +55,6 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target temperature."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_TEMPERATURE) is not None
)
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
@@ -83,32 +75,6 @@ class ClimateTargetTemperatureCrossedThresholdTrigger(
"""Trigger for climate target temperature value crossing a threshold."""
class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase):
"""Mixin for climate target humidity triggers."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}
_valid_unit = "%"
def _should_include(self, state: State) -> bool:
"""Skip climate entities that do not expose a target humidity."""
return (
super()._should_include(state)
and state.attributes.get(ATTR_HUMIDITY) is not None
)
class ClimateTargetHumidityChangedTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase
):
"""Trigger for climate target humidity value changes."""
class ClimateTargetHumidityCrossedThresholdTrigger(
_ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase
):
"""Trigger for climate target humidity value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -117,8 +83,14 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": ClimateTargetHumidityChangedTrigger,
"target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger,
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
+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")
@@ -3,10 +3,7 @@
import asyncio
from datetime import datetime, timedelta
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorEntity,
)
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -28,7 +25,6 @@ from homeassistant.util import dt as dt_util
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .sensor import CommandSensorData
from .utils import create_platform_yaml_not_supported_issue
DEFAULT_NAME = "Binary Command Sensor"
DEFAULT_PAYLOAD_ON = "ON"
@@ -45,7 +41,6 @@ async def async_setup_platform(
) -> None:
"""Set up the Command line Binary Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, BINARY_SENSOR_DOMAIN)
return
binary_sensor_config = discovery_info
@@ -4,7 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverEntity
from homeassistant.components.cover import CoverEntity
from homeassistant.const import (
CONF_COMMAND_CLOSE,
CONF_COMMAND_OPEN,
@@ -26,11 +26,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
from .utils import async_call_shell_with_timeout, async_check_output_or_log
SCAN_INTERVAL = timedelta(seconds=15)
@@ -43,7 +39,6 @@ async def async_setup_platform(
) -> None:
"""Set up cover controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, COVER_DOMAIN)
return
covers = []
@@ -4,29 +4,25 @@ import logging
import subprocess
from typing import Any
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
BaseNotificationService,
)
from homeassistant.components.notify import BaseNotificationService
from homeassistant.const import CONF_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.process import kill_subprocess
from .const import CONF_COMMAND_TIMEOUT, LOGGER
from .utils import create_platform_yaml_not_supported_issue, render_template_args
from .utils import render_template_args
_LOGGER = logging.getLogger(__name__)
async def async_get_service(
def get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> CommandLineNotificationService | None:
"""Get the Command Line notification service."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, NOTIFY_DOMAIN)
return None
notify_config = discovery_info
@@ -8,7 +8,6 @@ from typing import Any
from jsonpath import jsonpath
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
CONF_COMMAND,
CONF_NAME,
@@ -33,11 +32,7 @@ from .const import (
LOGGER,
TRIGGER_ENTITY_OPTIONS,
)
from .utils import (
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
render_template_args,
)
from .utils import async_check_output_or_log, render_template_args
DEFAULT_NAME = "Command Sensor"
@@ -52,7 +47,6 @@ async def async_setup_platform(
) -> None:
"""Set up the Command Sensor."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SENSOR_DOMAIN)
return
sensor_config = discovery_info
@@ -1,10 +1,4 @@
{
"issues": {
"platform_yaml_not_supported": {
"description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.",
"title": "Platform YAML is not supported in Command Line"
}
},
"services": {
"reload": {
"description": "Reloads command line configuration from the YAML-configuration.",
@@ -4,11 +4,7 @@ import asyncio
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
ENTITY_ID_FORMAT,
SwitchEntity,
)
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import (
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
@@ -29,11 +25,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, slugify
from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS
from .utils import (
async_call_shell_with_timeout,
async_check_output_or_log,
create_platform_yaml_not_supported_issue,
)
from .utils import async_call_shell_with_timeout, async_check_output_or_log
SCAN_INTERVAL = timedelta(seconds=30)
@@ -46,7 +38,6 @@ async def async_setup_platform(
) -> None:
"""Find and return switches controlled by shell commands."""
if not discovery_info:
create_platform_yaml_not_supported_issue(hass, SWITCH_DOMAIN)
return
switches = []
+1 -18
View File
@@ -4,10 +4,9 @@ import asyncio
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.template import Template
from .const import DOMAIN, LOGGER
from .const import LOGGER
_EXEC_FAILED_CODE = 127
@@ -92,19 +91,3 @@ def render_template_args(hass: HomeAssistant, command: str) -> str | None:
LOGGER.debug("Running command: %s", command)
return command
def create_platform_yaml_not_supported_issue(
hass: HomeAssistant, platform_domain: str
) -> None:
"""Create an issue when platform yaml is used."""
async_create_issue(
hass,
DOMAIN,
f"{platform_domain}_platform_yaml_not_supported",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="platform_yaml_not_supported",
translation_placeholders={"platform": platform_domain},
learn_more_url="https://www.home-assistant.io/integrations/command_line/",
)
@@ -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()
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.5.5"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.3.24"]
}
+18 -3
View File
@@ -1,6 +1,11 @@
"""Provides triggers for counters."""
from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM
from homeassistant.const import (
CONF_MAXIMUM,
CONF_MINIMUM,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
@@ -36,7 +41,9 @@ class CounterDecrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value decreased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) > int(to_state.state)
@@ -44,7 +51,9 @@ class CounterIncrementedTrigger(CounterBaseIntegerTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the counter value increased."""
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return int(from_state.state) < int(to_state.state)
@@ -53,6 +62,12 @@ class CounterValueBaseTrigger(EntityTriggerBase):
_domain_specs = {DOMAIN: DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != to_state.state
class CounterMaxReachedTrigger(CounterValueBaseTrigger):
"""Trigger for when a counter reaches its maximum value."""
+4 -2
View File
@@ -2,7 +2,7 @@
from collections.abc import Mapping
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
@@ -28,7 +28,9 @@ class CoverTriggerBase(EntityTriggerBase):
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check that the relevant cover value changed."""
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
@@ -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%]"
}
}
}
}
+23 -4
View File
@@ -6,19 +6,38 @@ from homeassistant.components.event import (
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import StatelessEntityTriggerBase, Trigger
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(StatelessEntityTriggerBase):
class DoorbellRangTrigger(EntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is ring."""
return state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
+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)
+25 -1
View File
@@ -2,6 +2,10 @@
import logging
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
@@ -12,7 +16,18 @@ from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_ACCESS_TOKEN): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -20,6 +35,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_setup_services(hass)
if DOMAIN not in config:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN]
)
)
return True
@@ -16,6 +16,7 @@ from homeassistant.helpers.selector import (
from .const import DOMAIN
from .helpers import update_duckdns
from .issue import deprecate_yaml_issue
_LOGGER = logging.getLogger(__name__)
@@ -67,6 +68,18 @@ class DuckDnsConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"url": "https://www.duckdns.org/"},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import config from yaml."""
self._async_abort_entries_match({CONF_DOMAIN: import_info[CONF_DOMAIN]})
result = await self.async_step_user(import_info)
if errors := result.get("errors"):
deprecate_yaml_issue(self.hass, import_success=False)
return self.async_abort(reason=errors["base"])
deprecate_yaml_issue(self.hass, import_success=True)
return result
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
+35 -1
View File
@@ -1,11 +1,45 @@
"""Issues for Duck DNS integration."""
from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import DOMAIN
@callback
def deprecate_yaml_issue(hass: HomeAssistant, *, import_success: bool) -> None:
"""Deprecate yaml issue."""
if import_success:
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2026.6.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Duck DNS",
},
)
else:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_error",
breaks_in_ha_version="2026.6.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_error",
translation_placeholders={
"url": "/config/integrations/dashboard/add?domain=duckdns"
},
)
def action_called_without_config_entry(hass: HomeAssistant) -> None:
"""Deprecate the use of action without config entry."""
@@ -49,6 +49,10 @@
"deprecated_call_without_config_entry": {
"description": "Calling the `duckdns.set_txt` action without specifying a config entry is deprecated.\n\nThe `config_entry_id` field will be required in a future release.\n\nPlease update your automations and scripts to include the `config_entry_id` parameter.",
"title": "Detected deprecated use of action without config entry"
},
"deprecated_yaml_import_issue_error": {
"description": "Configuring Duck DNS using YAML is being removed but there was an error when trying to import the YAML configuration.\n\nEnsure the YAML configuration is correct and restart Home Assistant to try again or remove the Duck DNS YAML configuration from your `configuration.yaml` file and continue to [set up the integration]({url}) manually.",
"title": "The Duck DNS YAML configuration import failed"
}
},
"services": {
+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 -16
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
@@ -13,9 +13,6 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .coordinator import DucoConfigEntry
# MAC addresses and serial numbers are redacted because a Duco installer or
# manufacturer could cross-reference them against an installation registry to
# identify the physical location of the device.
TO_REDACT = {
CONF_HOST,
"mac",
@@ -34,33 +31,22 @@ async def async_get_config_entry_diagnostics(
coordinator = entry.runtime_data
board = asdict(coordinator.board_info)
# `time` is a Unix epoch timestamp of the last board info fetch; not useful for support triage.
board.pop("time")
if board["public_api_version"] is None:
board.pop("public_api_version")
if board["software_version"] is None:
board.pop("software_version")
try:
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,
translation_key="connection_error",
) from err
api_info: dict[str, Any] = {"public_api_version": api_info_obj.public_api_version}
if api_info_obj.reported_api_version is not None:
api_info["reported_api_version"] = api_info_obj.reported_api_version
return async_redact_data(
{
"entry_data": entry.data,
"board_info": board,
"api_info": api_info,
"lan_info": asdict(lan_info),
"nodes": {
str(node_id): asdict(node)
+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

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