mirror of
https://github.com/home-assistant/core.git
synced 2026-05-12 11:44:13 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a215b82bd9 | |||
| 3393598d91 | |||
| 676df1d2b2 | |||
| 36cc629faf | |||
| 99b1e7c229 | |||
| cfdb00bf36 | |||
| 9b8c81cba1 | |||
| 095cf07f43 | |||
| b275791a71 | |||
| e7dccd3ad3 | |||
| adab0d6486 | |||
| aad964889f | |||
| 9200658526 | |||
| 68f10249a5 | |||
| b5ee78aeac | |||
| 86a967ee7b | |||
| eeca75b937 | |||
| ce6b6601fa | |||
| 4641c829ca | |||
| 56fbd096e2 | |||
| c071c08f86 | |||
| e47c152222 | |||
| 8232415fd5 | |||
| dcc95328ec | |||
| 85faab5d5d | |||
| bacb8a8fea | |||
| c9926915ff | |||
| 0772034d9d | |||
| 8cfdc52762 | |||
| 738b9936d9 | |||
| b3bb5c9abc | |||
| 3149da12a4 | |||
| e2805e4489 | |||
| 14a8ef6e48 | |||
| 015fc5809a | |||
| 2e4f4040c7 | |||
| 095de73a53 | |||
| 7dca14e78a | |||
| 0a974cbc7a | |||
| 2e37a0bba6 | |||
| 7e2ec795d6 | |||
| 7ba7700d5e | |||
| 261ca2dd9a | |||
| 284478f620 | |||
| 62ac3f9834 | |||
| 3bf57ae9cd | |||
| ed0abfb238 | |||
| 0789eb0db6 | |||
| 980d43accc | |||
| 6d8b010245 | |||
| dc9eba372a | |||
| 20827b66d9 | |||
| a43ab34302 | |||
| b14e863877 | |||
| af41b704d5 | |||
| d5f2cd8b17 | |||
| f96afda959 | |||
| 94bf13c6bb | |||
| b7dca79743 | |||
| df84d7a32d | |||
| c217acd7ab | |||
| f008f1501f | |||
| 739a5780b7 | |||
| 0ef221611a | |||
| 59e04c2169 | |||
| 5b0bf09fdc | |||
| c07d176467 | |||
| c39f0127ca | |||
| cdf5d39f57 | |||
| 90b6aa4d91 | |||
| f8ebc6c1e2 | |||
| e4b4503c10 | |||
| 7db1c855c1 | |||
| aa45f90a87 | |||
| cd945a42e6 | |||
| afc97268de | |||
| 497faeb103 | |||
| 84625678d3 | |||
| 95daee9f07 | |||
| ff1552e317 | |||
| ff6b69c929 | |||
| 2a74d5a81c | |||
| 52237247ae | |||
| 62d958682a | |||
| b2dad41d35 | |||
| 83c5dbb111 | |||
| cf73ef8a20 | |||
| 6555db12b1 | |||
| 20b81e9c74 | |||
| 51d004a5bb |
@@ -853,7 +853,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
mypy homeassistant pylint
|
||||
mypy --num-workers=4 homeassistant pylint
|
||||
- name: Run mypy (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
@@ -862,7 +862,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
|
||||
prepare-pytest-full:
|
||||
name: Split tests for full run
|
||||
|
||||
@@ -139,6 +139,7 @@ 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.*
|
||||
@@ -296,6 +297,7 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.indevolt.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
|
||||
Generated
+3
@@ -196,6 +196,7 @@ 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
|
||||
@@ -288,6 +289,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "mitsubishi",
|
||||
"name": "Mitsubishi",
|
||||
"integrations": ["melcloud", "mitsubishi_comfort"]
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.1"]
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: todo
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: done
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: The ozone sensor can still use the ozone device class.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
stale-devices: todo
|
||||
repair-issues: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -46,6 +46,9 @@
|
||||
"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.4.3"]
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import anthropic
|
||||
from anthropic.resources.messages.messages import DEPRECATED_MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -41,9 +40,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str]
|
||||
) -> data_entry_flow.FlowResult:
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
@@ -5,8 +5,7 @@ from typing import cast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
|
||||
@@ -21,14 +20,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
|
||||
raise ValueError("Missing data")
|
||||
self._data = data
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_disable_entity()
|
||||
|
||||
async def async_step_confirm_disable_entity(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
"""The avea component."""
|
||||
"""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)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""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"
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Support for the Elgato Avea lights."""
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import avea
|
||||
from bleak.exc import BleakError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -10,29 +13,153 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
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.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
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
|
||||
|
||||
def setup_platform(
|
||||
_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(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Avea platform."""
|
||||
"""Import the Avea YAML platform into config entries."""
|
||||
try:
|
||||
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
|
||||
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
|
||||
|
||||
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
|
||||
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)
|
||||
|
||||
|
||||
class AveaLight(LightEntity):
|
||||
@@ -41,7 +168,7 @@ class AveaLight(LightEntity):
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light):
|
||||
def __init__(self, light: avea.Bulb) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = light.name
|
||||
@@ -64,10 +191,7 @@ class AveaLight(LightEntity):
|
||||
self._light.set_brightness(0)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light.
|
||||
|
||||
This is the only method that should fetch new data for Home Assistant.
|
||||
"""
|
||||
"""Fetch new state data for this light."""
|
||||
if (brightness := self._light.get_brightness()) is not None:
|
||||
self._attr_is_on = brightness != 0
|
||||
self._attr_brightness = round(255 * (brightness / 4095))
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -64,5 +64,7 @@ class BroadlinkEntity(Entity):
|
||||
manufacturer=device.api.manufacturer,
|
||||
model=device.api.model,
|
||||
name=device.name,
|
||||
sw_version=device.fw_version,
|
||||
sw_version=str(device.fw_version)
|
||||
if device.fw_version is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
|
||||
# Read available heating circuits from config entry data
|
||||
# (populated by config flow or migration)
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
|
||||
DEFAULT_HEATING_CIRCUITS
|
||||
)
|
||||
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
# heating circuits from the device; fall back to [1] (pre-multi-circuit
|
||||
# default) if the device is unreachable or the endpoint is unsupported.
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
circuits: list[int] = [1]
|
||||
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
config = BSBLANConfig(
|
||||
host=entry.data[CONF_HOST],
|
||||
passkey=entry.data[CONF_PASSKEY],
|
||||
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
except (BSBLANError, TimeoutError) as err:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration failed for %s (%s); "
|
||||
"defaulting to single circuit [1]. Use Reconfigure to "
|
||||
"defaulting to a single circuit. Use Reconfigure to "
|
||||
"rediscover additional circuits later",
|
||||
entry.data[CONF_HOST],
|
||||
err,
|
||||
)
|
||||
if not circuits:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration returned no heating circuits "
|
||||
"for %s; defaulting to a single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
circuits,
|
||||
)
|
||||
|
||||
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
|
||||
# discovery. Every BSB-LAN setup has at least one heating circuit.
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if not entry.data[CONF_HEATING_CIRCUITS]:
|
||||
LOGGER.warning(
|
||||
"Stored heating circuits for %s are empty; defaulting to a "
|
||||
"single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
data = {
|
||||
**entry.data,
|
||||
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
|
||||
}
|
||||
else:
|
||||
data = {**entry.data}
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
|
||||
|
||||
return True
|
||||
|
||||
@@ -13,21 +13,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a BSBLAN config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize BSBLan flow."""
|
||||
self.host: str = ""
|
||||
self.port: int = DEFAULT_PORT
|
||||
self.mac: str | None = None
|
||||
self.circuits: list[int] = [1]
|
||||
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
self.passkey: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
@@ -384,6 +391,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
await bsblan.initialize()
|
||||
self.circuits = await bsblan.get_available_circuits()
|
||||
if not self.circuits:
|
||||
LOGGER.debug(
|
||||
"Circuit discovery returned no heating circuits for %s, "
|
||||
"defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
except (
|
||||
BSBLANError,
|
||||
TimeoutError,
|
||||
@@ -392,4 +406,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"Circuit discovery not available for %s, defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = [1]
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
@@ -22,4 +22,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
|
||||
CONF_PASSKEY: Final = "passkey"
|
||||
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
|
||||
|
||||
DEFAULT_HEATING_CIRCUITS: Final = (1,)
|
||||
DEFAULT_PORT: Final = 80
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -21,13 +20,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the initial step of the repair flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle confirmation, remove the bindkey, and reload the entry."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self._entry_id)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""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
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Constants for the CentriConnect/MyPropane API integration."""
|
||||
|
||||
DOMAIN = "centriconnect"
|
||||
|
||||
CENTRICONNECT_DEVICE_ID = "device_id"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import voluptuous as vol
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
repairs_flow_manager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DATA_CLOUD, DOMAIN
|
||||
@@ -50,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
wait_task: asyncio.Task | None = None
|
||||
_data: SubscriptionInfo | None = None
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_change_plan()
|
||||
|
||||
async def async_step_confirm_change_plan(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_change_plan()
|
||||
@@ -66,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
step_id="confirm_change_plan", data_schema=vol.Schema({})
|
||||
)
|
||||
|
||||
async def async_step_change_plan(self, _: None = None) -> FlowResult:
|
||||
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Wait for the user to authorize the app installation."""
|
||||
|
||||
cloud = self.hass.data[DATA_CLOUD]
|
||||
@@ -107,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
|
||||
return self.async_external_step_done(next_step_id="complete")
|
||||
|
||||
async def async_step_complete(self, _: None = None) -> FlowResult:
|
||||
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_timeout(self, _: None = None) -> FlowResult:
|
||||
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_abort(reason="operation_took_too_long")
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""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()
|
||||
@@ -30,6 +30,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_STOP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LINE): str,
|
||||
@@ -103,6 +109,34 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Diagnostics support for the Data Grand Lyon integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import DataGrandLyonConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"coordinator_data": {
|
||||
subentry_id: [asdict(passage) for passage in passages]
|
||||
for subentry_id, passages in coordinator.data.items()
|
||||
},
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"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%]",
|
||||
@@ -20,6 +21,16 @@
|
||||
"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%]",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The Denon RS232 integration."""
|
||||
"""The Denon RS-232 integration."""
|
||||
|
||||
from denon_rs232 import DenonReceiver, ReceiverState
|
||||
from denon_rs232.models import MODELS
|
||||
@@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Set up Denon RS232 from a config entry."""
|
||||
"""Set up Denon RS-232 from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
model = MODELS[entry.data[CONF_MODEL]]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for the Denon RS232 integration."""
|
||||
"""Config flow for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -63,7 +63,7 @@ async def _async_attempt_connect(port: str, model_key: str) -> str | None:
|
||||
|
||||
|
||||
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon RS232."""
|
||||
"""Handle a config flow for Denon RS-232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
"""Constants for the Denon RS-232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS232",
|
||||
"name": "Denon RS-232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
"""Media player platform for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
@@ -77,7 +77,7 @@ async def async_setup_entry(
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon RS232 media player."""
|
||||
"""Set up the Denon RS-232 media player."""
|
||||
receiver = config_entry.runtime_data
|
||||
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon receiver controlled over RS232."""
|
||||
"""Representation of a Denon receiver controlled over RS-232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -17,13 +16,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
"""The Duco integration."""
|
||||
|
||||
from duco import DucoClient, build_ssl_context
|
||||
import re
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
|
||||
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Set up Duco from a config entry."""
|
||||
ssl_context = await hass.async_add_executor_job(build_ssl_context)
|
||||
# Remove entity registry entries for the temperature and box_temperature
|
||||
# sensors that were removed when migrating to python-duco-connectivity.
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
):
|
||||
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(hass),
|
||||
host=entry.data[CONF_HOST],
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
coordinator = DucoCoordinator(hass, entry, client)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duco import DucoClient, build_ssl_context
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -158,11 +158,9 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Returns a tuple of (box_name, mac_address).
|
||||
"""
|
||||
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
lan_info = await client.async_get_lan_info()
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco.models import BoardInfo, Node
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
|
||||
api_info_obj = await coordinator.client.async_get_api_info()
|
||||
lan_info = await coordinator.client.async_get_lan_info()
|
||||
duco_diags = await coordinator.client.async_get_diagnostics()
|
||||
write_remaining = await coordinator.client.async_get_write_req_remaining()
|
||||
write_remaining = await coordinator.client.async_get_write_requests_remaining()
|
||||
except DucoConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco.models import Node
|
||||
from duco_connectivity.models import Node
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from duco.exceptions import DucoError, DucoRateLimitError
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.5.0"],
|
||||
"requirements": ["python-duco-connectivity==0.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -18,7 +18,6 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -54,20 +53,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
key="ventilation_state",
|
||||
translation_key="ventilation_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[s.lower() for s in VentilationState],
|
||||
options=[
|
||||
state.lower()
|
||||
for state in VentilationState
|
||||
if state != VentilationState.UNKNOWN
|
||||
],
|
||||
value_fn=lambda node: (
|
||||
node.ventilation.state.lower() if node.ventilation else None
|
||||
node.ventilation.state.lower()
|
||||
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
|
||||
else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="target_flow_level",
|
||||
translation_key="target_flow_level",
|
||||
@@ -92,17 +89,6 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="box_temperature",
|
||||
translation_key="box_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
|
||||
@@ -47,9 +47,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"box_temperature": {
|
||||
"name": "Box temperature"
|
||||
},
|
||||
"iaq_co2": {
|
||||
"name": "CO2 air quality index"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -24,7 +24,9 @@ async def _async_get_write_requests_remaining(
|
||||
) -> int | dict[str, str]:
|
||||
"""Get the remaining write-request quota for system health."""
|
||||
try:
|
||||
return await config_entry.runtime_data.client.async_get_write_req_remaining()
|
||||
return (
|
||||
await config_entry.runtime_data.client.async_get_write_requests_remaining()
|
||||
)
|
||||
except DucoConnectionError:
|
||||
return {"type": "failed", "error": "unreachable"}
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
|
||||
)
|
||||
|
||||
except EasyEnergyConnectionError as err:
|
||||
raise UpdateFailed("Error communicating with easyEnergy API") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
|
||||
return EasyEnergyData(
|
||||
energy_today=energy_today,
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"average_price": {
|
||||
"default": "mdi:cash-multiple"
|
||||
},
|
||||
"current_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"highest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"hours_priced_equal_or_higher": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"hours_priced_equal_or_lower": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"lowest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"max_price": {
|
||||
"default": "mdi:cash-plus"
|
||||
},
|
||||
"min_price": {
|
||||
"default": "mdi:cash-minus"
|
||||
},
|
||||
"next_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"percentage_of_max": {
|
||||
"default": "mdi:percent"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ from .coordinator import (
|
||||
EasyEnergyDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EasyEnergySensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_error": {
|
||||
"message": "Error communicating with the easyEnergy API."
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid date provided. Got {date}"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ECOWITT_BINARYSENSORS_MAPPING: Final = {
|
||||
EcoWittSensorTypes.LEAK: BinarySensorEntityDescription(
|
||||
key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE
|
||||
|
||||
@@ -38,6 +38,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ class BatterySourceType(TypedDict):
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
# statistic_id of a sensor (unit %) reporting the battery state of charge
|
||||
stat_soc: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -483,6 +486,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
vol.Optional("stat_soc"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .manager import async_replace_device
|
||||
@@ -43,7 +42,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -52,7 +51,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_migrate(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the migrate step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -66,7 +65,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the manual step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -16,6 +16,7 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"sync_time": {
|
||||
"default": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-up"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-down"
|
||||
},
|
||||
"offset": {
|
||||
"default": "mdi:thermometer-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Comet Blue number integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eurotronic_cometblue_ha import AsyncCometBlue
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .climate import MAX_TEMP, MIN_TEMP
|
||||
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
from .entity import CometBlueBluetoothEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CometBlueNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes a Comet Blue number entity."""
|
||||
|
||||
cometblue_key: str
|
||||
set_fn: Callable[[AsyncCometBlue], Any]
|
||||
|
||||
|
||||
DESCRIPTIONS = [
|
||||
CometBlueNumberEntityDescription(
|
||||
key="offset",
|
||||
cometblue_key="tempOffset",
|
||||
translation_key="offset",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=-5.0,
|
||||
native_max_value=5.0,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="eco_setpoint",
|
||||
cometblue_key="targetTempLow",
|
||||
translation_key="eco_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="comfort_setpoint",
|
||||
cometblue_key="targetTempHigh",
|
||||
translation_key="comfort_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CometBlueConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the client entities."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[CometBlueNumberEntity] = [
|
||||
CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity):
|
||||
"""Representation of a number."""
|
||||
|
||||
entity_description: CometBlueNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CometBlueDataUpdateCoordinator,
|
||||
description: CometBlueNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize CometBlueNumberEntity."""
|
||||
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.address}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.coordinator.data.temperatures.get(
|
||||
self.entity_description.cometblue_key
|
||||
)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the device."""
|
||||
|
||||
await self.coordinator.send_command(
|
||||
self.entity_description.set_fn(self.coordinator.device),
|
||||
{
|
||||
"values": {
|
||||
# manual temperature always needs to be set, otherwise TRV will turn OFF
|
||||
"manualTemp": self.coordinator.data.temperatures["manualTemp"],
|
||||
self.entity_description.cometblue_key: value,
|
||||
}
|
||||
},
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -35,6 +35,17 @@
|
||||
"sync_time": {
|
||||
"name": "Sync time"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"name": "Comfort setpoint"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"name": "Eco setpoint"
|
||||
},
|
||||
"offset": {
|
||||
"name": "Setpoint offset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_PRESET_MODE,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
|
||||
PRESET_NONE,
|
||||
@@ -451,6 +452,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
return
|
||||
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
|
||||
self._target_temp = temperature
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
await self._async_control_heating(force=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1201,6 +1201,17 @@ class TemperatureSettingTrait(_Trait):
|
||||
preset_to_google = {climate.PRESET_ECO: "eco"}
|
||||
google_to_preset = {value: key for key, value in preset_to_google.items()}
|
||||
|
||||
action_to_google = {
|
||||
climate.HVACAction.OFF: "off",
|
||||
climate.HVACAction.HEATING: "heat",
|
||||
climate.HVACAction.DEFROSTING: "heat",
|
||||
climate.HVACAction.PREHEATING: "heat",
|
||||
climate.HVACAction.COOLING: "cool",
|
||||
climate.HVACAction.DRYING: "dry",
|
||||
climate.HVACAction.FAN: "fan-only",
|
||||
climate.HVACAction.IDLE: "none",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
@@ -1284,6 +1295,11 @@ class TemperatureSettingTrait(_Trait):
|
||||
else:
|
||||
response["thermostatMode"] = self.hvac_to_google.get(operation, "none")
|
||||
|
||||
if (
|
||||
action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION))
|
||||
) is not None:
|
||||
response["activeThermostatMode"] = action
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
response["thermostatTemperatureAmbient"] = round(
|
||||
|
||||
@@ -172,7 +172,7 @@ def _format_tool(
|
||||
def _escape_decode(value: Any) -> Any:
|
||||
"""Recursively call codecs.escape_decode on all values."""
|
||||
if isinstance(value, str):
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8")
|
||||
if isinstance(value, list):
|
||||
return [_escape_decode(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
|
||||
@@ -8,10 +8,9 @@ from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_list
|
||||
from .const import (
|
||||
@@ -77,7 +76,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
return placeholders or None
|
||||
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
|
||||
"""Return form for suggestion."""
|
||||
return self.async_show_form(
|
||||
step_id=suggestion.key,
|
||||
@@ -86,7 +85,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
# Out of sync with supervisor, issue is resolved or not fixable. Remove it
|
||||
if not self.issue or not self.issue.suggestions:
|
||||
@@ -108,7 +107,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
# Always show a form for one suggestion to explain to user what's happening
|
||||
return self._async_form_for_suggestion(self.issue.suggestions[0])
|
||||
|
||||
async def async_step_fix_menu(self, _: None = None) -> FlowResult:
|
||||
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Show the fix menu."""
|
||||
assert self.issue
|
||||
|
||||
@@ -120,7 +119,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
async def _async_step_apply_suggestion(
|
||||
self, suggestion: Suggestion, confirmed: bool = False
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle applying a suggestion as a flow step. Optionally request confirmation."""
|
||||
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
|
||||
return self._async_form_for_suggestion(suggestion)
|
||||
@@ -137,13 +136,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
suggestion: Suggestion,
|
||||
) -> Callable[
|
||||
[SupervisorIssueRepairFlow, dict[str, str] | None],
|
||||
Coroutine[Any, Any, FlowResult],
|
||||
Coroutine[Any, Any, RepairsFlowResult],
|
||||
]:
|
||||
"""Generate a step handler for a suggestion."""
|
||||
|
||||
async def _async_step(
|
||||
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle a flow step for a suggestion."""
|
||||
return await self._async_step_apply_suggestion(
|
||||
suggestion, confirmed=user_input is not None
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
Folder,
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
@@ -70,6 +71,31 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
|
||||
# for backwards compatibility with existing automations.
|
||||
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
|
||||
|
||||
|
||||
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map legacy aliases used by both partial backup and partial restore handlers."""
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
if ATTR_FOLDERS in data:
|
||||
folders: set[Any] = set(data[ATTR_FOLDERS])
|
||||
if LEGACY_FOLDER_HOMEASSISTANT in folders:
|
||||
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
|
||||
if data.get(ATTR_HOMEASSISTANT) is False:
|
||||
raise ServiceValidationError(
|
||||
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
|
||||
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
|
||||
)
|
||||
data[ATTR_HOMEASSISTANT] = True
|
||||
if folders:
|
||||
data[ATTR_FOLDERS] = folders
|
||||
else:
|
||||
data.pop(ATTR_FOLDERS)
|
||||
return data
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
@@ -113,7 +139,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -136,7 +165,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -343,9 +375,7 @@ def async_register_backup_restore_services(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(service.data.copy())
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
@@ -392,8 +422,7 @@ def async_register_backup_restore_services(
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(data)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
|
||||
from pycec.const import (
|
||||
CMD_STANDBY,
|
||||
KEY_BACKWARD,
|
||||
KEY_FORWARD,
|
||||
KEY_MUTE_TOGGLE,
|
||||
@@ -93,7 +94,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycec.const import POWER_OFF, POWER_ON
|
||||
from pycec.commands import CecCommand
|
||||
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -50,7 +51,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Repairs for Home Assistant."""
|
||||
|
||||
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -18,7 +21,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -28,7 +31,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
entries = self.hass.config_entries.async_entries(self.domain)
|
||||
for entry in entries:
|
||||
@@ -37,7 +40,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
|
||||
|
||||
async def async_step_ignore(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the ignore step of a fix flow."""
|
||||
ir.async_get(self.hass).async_ignore(
|
||||
DOMAIN, f"integration_not_found.{self.domain}", True
|
||||
@@ -58,7 +61,7 @@ class OrphanedConfigEntryFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -68,14 +71,14 @@ class OrphanedConfigEntryFlow(RepairsFlow):
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
await self.hass.config_entries.async_remove(self.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_ignore(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the ignore step of a fix flow."""
|
||||
ir.async_get(self.hass).async_ignore(
|
||||
DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Provides diagnostics for the Home Assistant Connect ZBT-2 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {"config_entry": config_entry.as_dict()}
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.enums import (
|
||||
BinaryBehaviorType,
|
||||
LockState,
|
||||
SmokeDetectorAlarmType,
|
||||
WindowState,
|
||||
)
|
||||
from homematicip.base.functionalChannels import MultiModeInputChannel
|
||||
from homematicip.device import (
|
||||
AccelerationSensor,
|
||||
@@ -352,7 +357,22 @@ class HomematicipFullFlushLockControllerLocked(
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the controlled lock is locked."""
|
||||
"""Return true if the controlled lock is unlocked.
|
||||
|
||||
Per HA's BinarySensorDeviceClass.LOCK contract, ON means
|
||||
unlocked / open and OFF means locked / closed.
|
||||
|
||||
The mapping from the firmware-reported ``lockState`` depends on
|
||||
the channel's ``binaryBehaviorType``. With the default
|
||||
``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState
|
||||
flips to ``LOCKED``) when the contact closes — i.e. when a
|
||||
magnetic door contact registers the door as closed. With
|
||||
``NORMALLY_CLOSE`` the same physical event puts the input into
|
||||
the IDLE state (lockState ``UNLOCKED``). To present the same
|
||||
HA semantics regardless of which way the user wired the
|
||||
contact, ``lockState`` is interpreted relative to the
|
||||
configured behavior.
|
||||
"""
|
||||
channel = _get_channel_by_role(
|
||||
self._device,
|
||||
"MULTI_MODE_LOCK_INPUT_CHANNEL",
|
||||
@@ -361,7 +381,15 @@ class HomematicipFullFlushLockControllerLocked(
|
||||
if channel is None:
|
||||
return False
|
||||
lock_state = getattr(channel, "lockState", None)
|
||||
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
|
||||
is_locked_state = (
|
||||
getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
|
||||
)
|
||||
binary_behavior = getattr(channel, "binaryBehaviorType", None)
|
||||
normally_close = (
|
||||
getattr(binary_behavior, "name", binary_behavior)
|
||||
== BinaryBehaviorType.NORMALLY_CLOSE.name
|
||||
)
|
||||
return is_locked_state if normally_close else not is_locked_state
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerGlassBreak(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Repairs for HomeWizard integration."""
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .config_flow import async_request_token
|
||||
|
||||
@@ -19,14 +17,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
|
||||
if user_input is not None:
|
||||
@@ -38,7 +36,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the authorize step of a fix flow."""
|
||||
|
||||
ip_address = self.entry.data[CONF_IP_ADDRESS]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import RadioFrequencyCommand
|
||||
from rf_protocols.codes.honeywell.string_lights import CODES
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import async_get_transmitters
|
||||
@@ -11,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN
|
||||
from .light import COMMANDS
|
||||
|
||||
|
||||
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -24,7 +24,7 @@ class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
|
||||
COMMANDS.load_command, "turn_on"
|
||||
CODES.load_command, "turn_on"
|
||||
)
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import get_codes
|
||||
from rf_protocols.codes.honeywell.string_lights import CODES
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
@@ -16,8 +16,6 @@ from .entity import HoneywellStringLightsEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
COMMANDS = get_codes("honeywell/string_lights")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -57,7 +55,7 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti
|
||||
|
||||
async def _async_send_command(self, name: str) -> None:
|
||||
"""Load the named command and send it via the configured transmitter."""
|
||||
command = await COMMANDS.async_load_command(name)
|
||||
command = await CODES.async_load_command(name)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "iaqualink"
|
||||
UPDATE_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
|
||||
"iaqua": timedelta(seconds=15),
|
||||
"exo": timedelta(seconds=60),
|
||||
}
|
||||
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, UPDATE_INTERVAL
|
||||
from .const import DOMAIN, UPDATE_INTERVAL_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,12 +26,15 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
self, hass: HomeAssistant, config_entry: ConfigEntry, system: Any
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
update_interval = UPDATE_INTERVAL_BY_SYSTEM_TYPE.get(
|
||||
system.NAME, UPDATE_INTERVAL_DEFAULT
|
||||
)
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_{system.serial}",
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
update_interval=update_interval,
|
||||
)
|
||||
self.system = system
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["ihcsdk"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.5"]
|
||||
"requirements": ["defusedxml==0.7.1", "ihcsdk==2.8.12"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["imgw_pib==2.1.1"]
|
||||
"requirements": ["imgw_pib==2.1.2"]
|
||||
}
|
||||
|
||||
@@ -27,13 +27,25 @@ SENSOR_KEYS: Final[dict[int, list[str]]] = {
|
||||
IndevoltSystem.INPUT_POWER,
|
||||
IndevoltSystem.OUTPUT_POWER,
|
||||
IndevoltSystem.TOTAL_INPUT_ENERGY,
|
||||
IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||
IndevoltBattery.POWER,
|
||||
IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
IndevoltSystem.BYPASS_POWER,
|
||||
IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||
IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||
IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
||||
IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
||||
IndevoltBattery.TOTAL_DISCHARGING_ENERGY,
|
||||
IndevoltBattery.CHARGE_DISCHARGE_STATE,
|
||||
IndevoltBattery.SOC,
|
||||
IndevoltSolar.DC_OUTPUT_POWER,
|
||||
IndevoltSolar.DAILY_PRODUCTION,
|
||||
IndevoltSolar.DC_INPUT_POWER_1,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_1,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_1,
|
||||
IndevoltSolar.DC_INPUT_POWER_2,
|
||||
IndevoltSolar.DC_INPUT_VOLTAGE_2,
|
||||
IndevoltSolar.DC_INPUT_CURRENT_2,
|
||||
IndevoltSolar.DC_INPUT_POWER_3,
|
||||
IndevoltSolar.DC_INPUT_POWER_4,
|
||||
IndevoltConfig.READ_DISCHARGE_LIMIT,
|
||||
|
||||
@@ -33,14 +33,6 @@ SCAN_INTERVAL: Final = 30
|
||||
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
||||
|
||||
|
||||
class DeviceTimeoutError(HomeAssistantError):
|
||||
"""Raised when device push times out."""
|
||||
|
||||
|
||||
class DeviceConnectionError(HomeAssistantError):
|
||||
"""Raised when device push fails due to connection issues."""
|
||||
|
||||
|
||||
class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching and pushing data to indevolt devices."""
|
||||
|
||||
@@ -96,12 +88,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
try:
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
except TimeoutError as err:
|
||||
raise DeviceTimeoutError(f"Device push timed out: {err}") from err
|
||||
except (ClientError, OSError) as err:
|
||||
raise DeviceConnectionError(f"Device push failed: {err}") from err
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
|
||||
async def async_switch_energy_mode(
|
||||
self, target_mode: IndevoltEnergyMode, refresh: bool = True
|
||||
@@ -125,15 +112,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# Switch energy mode if required
|
||||
if current_mode != target_mode:
|
||||
try:
|
||||
success = await self.async_push_data(
|
||||
IndevoltConfig.WRITE_ENERGY_MODE, target_mode
|
||||
)
|
||||
except (DeviceTimeoutError, DeviceConnectionError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_switch_energy_mode",
|
||||
) from err
|
||||
success = await self.async_push_data(
|
||||
IndevoltConfig.WRITE_ENERGY_MODE, target_mode
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"stop": {
|
||||
"default": "mdi:stop"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"energy_mode": {
|
||||
"default": "mdi:home-lightning-bolt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"charge": {
|
||||
"service": "mdi:battery-arrow-up"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["indevolt-api==1.7.1"]
|
||||
"requirements": ["indevolt-api==1.7.2"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -138,4 +139,8 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set value {int_value} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -46,13 +46,13 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Integration does not support network discovery
|
||||
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
|
||||
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: Integration represents a single device, not a hub with multiple devices
|
||||
@@ -60,8 +60,8 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
@@ -73,4 +73,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
strict-typing: done
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -108,4 +109,8 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Sensor platform for Indevolt integration."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
from typing import Final, cast
|
||||
|
||||
from indevolt_api import (
|
||||
IndevoltBattery,
|
||||
@@ -100,7 +100,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSystem.BYPASS_POWER,
|
||||
generation=(2,),
|
||||
translation_key="bypass_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
@@ -116,7 +115,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSystem.TOTAL_OUTPUT_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="total_ac_output_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -124,7 +122,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSystem.OFF_GRID_OUTPUT_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="off_grid_output_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -132,7 +129,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSystem.BYPASS_INPUT_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="bypass_input_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -140,7 +136,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.DAILY_CHARGING_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="battery_daily_charging_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -148,7 +143,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.DAILY_DISCHARGING_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="battery_daily_discharging_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -156,7 +150,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.TOTAL_CHARGING_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="battery_total_charging_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -164,7 +157,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltBattery.TOTAL_DISCHARGING_ENERGY,
|
||||
generation=(2,),
|
||||
translation_key="battery_total_discharging_energy",
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
@@ -252,7 +244,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSolar.DC_INPUT_CURRENT_1,
|
||||
generation=(2,),
|
||||
translation_key="dc_input_current_1",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
@@ -261,7 +252,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSolar.DC_INPUT_VOLTAGE_1,
|
||||
generation=(2,),
|
||||
translation_key="dc_input_voltage_1",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
@@ -278,7 +268,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSolar.DC_INPUT_CURRENT_2,
|
||||
generation=(2,),
|
||||
translation_key="dc_input_current_2",
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
@@ -287,7 +276,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key=IndevoltSolar.DC_INPUT_VOLTAGE_2,
|
||||
generation=(2,),
|
||||
translation_key="dc_input_voltage_2",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
@@ -780,4 +768,4 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity):
|
||||
if self.entity_description.device_class == SensorDeviceClass.ENUM:
|
||||
return self.entity_description.state_mapping.get(raw_value)
|
||||
|
||||
return raw_value
|
||||
return cast(str | int | float, raw_value)
|
||||
|
||||
@@ -354,6 +354,9 @@
|
||||
},
|
||||
"soc_below_minimum": {
|
||||
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
|
||||
},
|
||||
"write_error": {
|
||||
"message": "Cannot update value for {name}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -128,4 +129,8 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set value {value} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==3.5.0"]
|
||||
"requirements": ["infrared-protocols==5.1.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool:
|
||||
|
||||
@@ -10,3 +10,4 @@ PORT = 8081
|
||||
POLL_INTERVAL = 15
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_SSL_VERIFY = False
|
||||
REFRESH_DELAY = 0.5
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"last_motion": {
|
||||
"default": "mdi:motion-sensor"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
"last_motion": {
|
||||
"name": "Last motion"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"name": "Disable screensaver"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user