Compare commits

..

1 Commits

Author SHA1 Message Date
mib1185 047a9cd189 add data descriptions for all fields 2026-05-09 18:05:36 +00:00
393 changed files with 2130 additions and 13149 deletions
+2 -2
View File
@@ -853,7 +853,7 @@ jobs:
run: |
. venv/bin/activate
python --version
mypy --num-workers=4 homeassistant pylint
mypy 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 --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
prepare-pytest-full:
name: Split tests for full run
-2
View File
@@ -139,7 +139,6 @@ homeassistant.components.cambridge_audio.*
homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.centriconnect.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*
@@ -297,7 +296,6 @@ homeassistant.components.imap.*
homeassistant.components.imgw_pib.*
homeassistant.components.immich.*
homeassistant.components.incomfort.*
homeassistant.components.indevolt.*
homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.*
Generated
-3
View File
@@ -196,7 +196,6 @@ CLAUDE.md @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/tests/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
/homeassistant/components/aws_s3/ @tomasbedrich
@@ -289,8 +288,6 @@ 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
-5
View File
@@ -1,5 +0,0 @@
{
"domain": "mitsubishi",
"name": "Mitsubishi",
"integrations": ["melcloud", "mitsubishi_comfort"]
}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["serialx==1.7.3"]
"requirements": ["serialx==1.7.1"]
}
@@ -1,67 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling:
status: done
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
brands: done
common-modules: done
config-flow-test-coverage: todo
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: done
discovery: todo
discovery-update-info: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class:
status: todo
comment: The ozone sensor can still use the ozone device class.
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
stale-devices: todo
repair-issues: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -46,9 +46,6 @@
"init": {
"data": {
"radius": "Station radius (miles)"
},
"data_description": {
"radius": "The radius in miles around your location to search for reporting stations."
}
}
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==13.5.0"]
"requirements": ["aioamazondevices==13.4.3"]
}
@@ -7,7 +7,8 @@ import anthropic
from anthropic.resources.messages.messages import DEPRECATED_MODELS
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -40,7 +41,9 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
self._current_subentry_id = None
self._model_list_cache = None
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
async def async_step_init(
self, user_input: dict[str, str]
) -> data_entry_flow.FlowResult:
"""Handle the steps of a fix flow."""
if user_input.get(CONF_CHAT_MODEL):
self._async_update_current_subentry(user_input)
@@ -5,7 +5,8 @@ from typing import cast
import voluptuous as vol
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.components.repairs import RepairsFlow
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import entity_registry as er
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
@@ -20,14 +21,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
raise ValueError("Missing data")
self._data = data
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
async def async_step_init(self, _: None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_disable_entity()
async def async_step_confirm_disable_entity(
self,
user_input: dict[str, str] | None = None,
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entity_registry = er.async_get(self.hass)
+1 -33
View File
@@ -1,33 +1 @@
"""The Avea integration."""
import avea
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
type AveaConfigEntry = ConfigEntry[avea.Bulb]
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Set up Avea from a config entry."""
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
)
entry.runtime_data = avea.Bulb(ble_device)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
"""Unload an Avea config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
"""The avea component."""
@@ -1,216 +0,0 @@
"""Config flow for Avea."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from .const import AVEA_SERVICE_UUID, DOMAIN, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _validate_device(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Validate the device is reachable and return a title for it."""
bulb = avea.Bulb(discovery_info.device)
try:
if not bulb.connect():
raise CannotConnect
try:
name = bulb.get_name()
except BleakError, OSError, RuntimeError:
_LOGGER.debug(
"Failed to get name for Avea device %s",
discovery_info.address,
exc_info=True,
)
name = None
brightness = bulb.get_brightness()
except (BleakError, OSError, RuntimeError) as err:
raise CannotConnect from err
finally:
with suppress(BleakError, OSError, RuntimeError):
bulb.close()
if brightness is None:
raise CannotConnect
return (
_normalize_name(name)
or _normalize_name(discovery_info.name)
or discovery_info.address
)
def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
"""Return if the bluetooth discovery matches an Avea bulb."""
return AVEA_SERVICE_UUID in discovery_info.service_uuids
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": discovery_info.name or discovery_info.address
}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the discovered device before creating the entry."""
assert self._discovery_info is not None
errors: dict[str, str] = {}
if user_input is not None:
try:
title = await self.hass.async_add_executor_job(
_validate_device, self._discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: self._discovery_info.address},
)
self.context["title_placeholders"] = {
"name": self._discovery_info.name or self._discovery_info.address
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=self.context["title_placeholders"],
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
try:
title = await self.hass.async_add_executor_job(
_validate_device, discovery_info
)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error while validating Avea device")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: address},
)
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or not _is_avea_discovery(discovery)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
if self._discovery_info:
data_schema = vol.Schema(
{
vol.Required(
CONF_ADDRESS, default=self._discovery_info.address
): vol.In(
{
self._discovery_info.address: (
f"{self._discovery_info.name or self._discovery_info.address}"
f" ({self._discovery_info.address})"
)
}
)
}
)
else:
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
address = import_data[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=import_data.get(CONF_NAME, address),
data={CONF_ADDRESS: address},
)
class CannotConnect(Exception):
"""Error to indicate an Avea device cannot be connected to."""
-8
View File
@@ -1,8 +0,0 @@
"""Constants for the Avea integration."""
DOMAIN = "avea"
INTEGRATION_TITLE = "Elgato Avea"
MANUFACTURER = "Elgato"
MODEL = "Avea"
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
UNKNOWN_NAME = "Unknown"
+18 -142
View File
@@ -1,11 +1,8 @@
"""Light platform for Avea."""
"""Support for the Elgato Avea lights."""
from contextlib import suppress
import logging
from typing import Any
import avea
from bleak.exc import BleakError
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -13,153 +10,29 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import AveaConfigEntry
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
_LOGGER = logging.getLogger(__name__)
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
BREAKS_IN_HA_VERSION = "2026.12.0"
def _normalize_name(name: str | None) -> str | None:
"""Return a valid Avea name."""
if not name or name == UNKNOWN_NAME:
return None
return name
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
"""Create the deprecated YAML issue for Avea."""
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
def _create_yaml_import_failed_issue(hass: HomeAssistant) -> None:
"""Create a repair issue when the Avea YAML import cannot find bulbs."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_no_bulbs",
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_no_bulbs",
translation_placeholders={
"domain": DOMAIN,
"integration_title": INTEGRATION_TITLE,
},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AveaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Avea light platform."""
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
def _discover_bulbs_for_import() -> list[dict[str, str]]:
"""Discover and validate Avea bulbs for YAML import."""
discovered_bulbs: list[dict[str, str]] = []
for bulb in avea.discover_avea_bulbs():
address = bulb.addr
try:
name = bulb.get_name()
brightness = bulb.get_brightness()
except UPDATE_EXCEPTIONS as err:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: %s",
address,
err,
)
continue
finally:
with suppress(*UPDATE_EXCEPTIONS):
bulb.close()
if brightness is None:
_LOGGER.warning(
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
address,
)
continue
discovered_bulbs.append(
{
CONF_ADDRESS: address,
CONF_NAME: _normalize_name(name)
or _normalize_name(bulb.name)
or address,
}
)
return discovered_bulbs
async def async_setup_platform(
def setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Import the Avea YAML platform into config entries."""
"""Set up the Avea platform."""
try:
bulbs = await hass.async_add_executor_job(_discover_bulbs_for_import)
except UPDATE_EXCEPTIONS as err:
raise PlatformNotReady("Could not discover Avea bulbs for YAML import") from err
nearby_bulbs = avea.discover_avea_bulbs()
for bulb in nearby_bulbs:
bulb.get_name()
bulb.get_brightness()
except OSError as err:
raise PlatformNotReady from err
if not bulbs:
_create_yaml_import_failed_issue(hass)
for bulb in bulbs:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=bulb,
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
_LOGGER.warning(
"Skipping Avea YAML import for bulb %s: %s",
bulb[CONF_ADDRESS],
result.get("reason"),
)
continue
_create_deprecated_yaml_issue(hass)
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
class AveaLight(LightEntity):
@@ -168,7 +41,7 @@ class AveaLight(LightEntity):
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, light: avea.Bulb) -> None:
def __init__(self, light):
"""Initialize an AveaLight."""
self._light = light
self._attr_name = light.name
@@ -191,7 +64,10 @@ class AveaLight(LightEntity):
self._light.set_brightness(0)
def update(self) -> None:
"""Fetch new state data for this light."""
"""Fetch new state data for this light.
This is the only method that should fetch new data for Home Assistant.
"""
if (brightness := self._light.get_brightness()) is not None:
self._attr_is_on = brightness != 0
self._attr_brightness = round(255 * (brightness / 4095))
+1 -9
View File
@@ -1,18 +1,10 @@
{
"domain": "avea",
"name": "Elgato Avea",
"bluetooth": [
{
"local_name": "Avea*",
"service_uuid": "f815e810-456c-6761-746f-4d756e696368"
}
],
"codeowners": ["@pattyland"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/avea",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["avea"],
"quality_scale": "legacy",
"requirements": ["avea==1.6.1"]
}
@@ -1,35 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
},
"description": "[%key:component::bluetooth::config::step::user::description%]"
}
}
},
"issues": {
"deprecated_yaml": {
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
},
"deprecated_yaml_import_issue_no_bulbs": {
"description": "Configuring {integration_title} using YAML is deprecated and will be removed in a future release. While importing your YAML configuration, Home Assistant could not discover any Avea bulbs. Make sure the bulbs are powered on, nearby, and reachable over Bluetooth, then restart Home Assistant. If you no longer use the YAML configuration, remove the `{domain}` entry from your `configuration.yaml` file.",
"title": "Avea YAML configuration import failed"
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==71"],
"requirements": ["axis==70"],
"ssdp": [
{
"manufacturer": "AXIS"
+1 -3
View File
@@ -64,7 +64,5 @@ class BroadlinkEntity(Entity):
manufacturer=device.api.manufacturer,
model=device.api.model,
name=device.name,
sw_version=str(device.fw_version)
if device.fw_version is not None
else None,
sw_version=device.fw_version,
)
+4 -38
View File
@@ -38,14 +38,7 @@ from homeassistant.helpers.device_registry import (
)
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
from .services import async_setup_services
@@ -125,9 +118,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
# Read available heating circuits from config entry data
# (populated by config flow or migration)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
DEFAULT_HEATING_CIRCUITS
)
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
# Fetch required device metadata in parallel for faster startup
device, info = await asyncio.gather(
@@ -238,7 +229,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
# heating circuits from the device; fall back to [1] (pre-multi-circuit
# default) if the device is unreachable or the endpoint is unsupported.
if entry.version == 1 and entry.minor_version < 2:
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
circuits: list[int] = [1]
config = BSBLANConfig(
host=entry.data[CONF_HOST],
passkey=entry.data[CONF_PASSKEY],
@@ -254,18 +245,11 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
except (BSBLANError, TimeoutError) as err:
LOGGER.warning(
"Circuit discovery during migration failed for %s (%s); "
"defaulting to a single circuit. Use Reconfigure to "
"defaulting to single circuit [1]. Use Reconfigure to "
"rediscover additional circuits later",
entry.data[CONF_HOST],
err,
)
if not circuits:
LOGGER.warning(
"Circuit discovery during migration returned no heating circuits "
"for %s; defaulting to a single circuit",
entry.data[CONF_HOST],
)
circuits = list(DEFAULT_HEATING_CIRCUITS)
hass.config_entries.async_update_entry(
entry,
@@ -279,22 +263,4 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
circuits,
)
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
# discovery. Every BSB-LAN setup has at least one heating circuit.
if entry.version == 1 and entry.minor_version < 3:
if not entry.data[CONF_HEATING_CIRCUITS]:
LOGGER.warning(
"Stored heating circuits for %s are empty; defaulting to a "
"single circuit",
entry.data[CONF_HOST],
)
data = {
**entry.data,
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
}
else:
data = {**entry.data}
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
return True
+4 -18
View File
@@ -13,28 +13,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_HEATING_CIRCUITS,
CONF_PASSKEY,
DEFAULT_HEATING_CIRCUITS,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a BSBLAN config flow."""
VERSION = 1
MINOR_VERSION = 3
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize BSBLan flow."""
self.host: str = ""
self.port: int = DEFAULT_PORT
self.mac: str | None = None
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
self.circuits: list[int] = [1]
self.passkey: str | None = None
self.username: str | None = None
self.password: str | None = None
@@ -391,13 +384,6 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
try:
await bsblan.initialize()
self.circuits = await bsblan.get_available_circuits()
if not self.circuits:
LOGGER.debug(
"Circuit discovery returned no heating circuits for %s, "
"defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
except (
BSBLANError,
TimeoutError,
@@ -406,4 +392,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
"Circuit discovery not available for %s, defaulting to single circuit",
self.host,
)
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
self.circuits = [1]
-1
View File
@@ -22,5 +22,4 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
CONF_PASSKEY: Final = "passkey"
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
DEFAULT_HEATING_CIRCUITS: Final = (1,)
DEFAULT_PORT: Final = 80
+4 -3
View File
@@ -2,7 +2,8 @@
from typing import Any
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -20,13 +21,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the initial step of the repair flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle confirmation, remove the bindkey, and reload the entry."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self._entry_id)
@@ -1,30 +0,0 @@
"""The CentriConnect/MyPropane API integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(
hass: HomeAssistant, entry: CentriConnectConfigEntry
) -> bool:
"""Set up CentriConnect/MyPropane API from a config entry."""
coordinator = CentriConnectCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: CentriConnectConfigEntry
) -> bool:
"""Unload CentriConnect/MyPropane API integration platforms and coordinator."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -1,89 +0,0 @@
"""Config flow for the CentriConnect/MyPropane API integration."""
import logging
from typing import Any
from aiocentriconnect import CentriConnect
from aiocentriconnect.exceptions import (
CentriConnectConnectionError,
CentriConnectDecodeError,
CentriConnectEmptyResponseError,
CentriConnectNotFoundError,
CentriConnectTooManyRequestsError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CENTRICONNECT_DEVICE_ID, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_DEVICE_ID): str,
vol.Required(CONF_PASSWORD): str,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
# Validate the user-supplied data can be used to set up a connection.
hub = CentriConnect(
data[CONF_USERNAME],
data[CONF_DEVICE_ID],
data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)
tank_data = await hub.async_get_tank_data()
# Return info to store in the config entry.
return {
"title": tank_data.device_name,
CENTRICONNECT_DEVICE_ID: tank_data.device_id,
}
class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for CentriConnect/MyPropane API."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CentriConnectConnectionError, CentriConnectTooManyRequestsError:
errors["base"] = "cannot_connect"
except CentriConnectNotFoundError:
errors["base"] = "invalid_auth"
except CentriConnectEmptyResponseError, CentriConnectDecodeError:
errors["base"] = "unknown"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True
)
self._abort_if_unique_id_configured(
updates=user_input, reload_on_update=True
)
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
@@ -1,5 +0,0 @@
"""Constants for the CentriConnect/MyPropane API integration."""
DOMAIN = "centriconnect"
CENTRICONNECT_DEVICE_ID = "device_id"
@@ -1,88 +0,0 @@
"""Coordinator for CentriConnect/MyPropane API integration.
Responsible for polling the device API endpoint and normalizing data for entities.
"""
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiocentriconnect import CentriConnect, Tank
from aiocentriconnect.exceptions import CentriConnectConnectionError, CentriConnectError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
COORDINATOR_NAME = f"{DOMAIN} Coordinator"
# Maximum update frequency is every 6 hours. The API will return 429 Too Many Requests if polled frequently.
# The device updates its data every 8-12 hours, so there's no need to poll more frequently.
UPDATE_INTERVAL = timedelta(hours=6)
type CentriConnectConfigEntry = ConfigEntry[CentriConnectCoordinator]
@dataclass
class CentriConnectDeviceInfo:
"""Data about the CentriConnect device."""
device_id: str
device_name: str
hardware_version: str
lte_version: str
tank_size: int
tank_size_unit: str
class CentriConnectCoordinator(DataUpdateCoordinator[Tank]):
"""Data update coordinator for CentriConnect/MyPropane devices."""
config_entry: CentriConnectConfigEntry
device_info: CentriConnectDeviceInfo
def __init__(self, hass: HomeAssistant, entry: CentriConnectConfigEntry) -> None:
"""Initialize the CentriConnect data update coordinator."""
super().__init__(
hass,
logger=_LOGGER,
name=COORDINATOR_NAME,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api_client = CentriConnect(
entry.data[CONF_USERNAME],
entry.data[CONF_DEVICE_ID],
entry.data[CONF_PASSWORD],
session=async_get_clientsession(hass),
)
async def _async_setup(self) -> None:
try:
tank_data = await self.api_client.async_get_tank_data()
except CentriConnectError as err:
raise UpdateFailed("Could not fetch device info") from err
self.device_info = CentriConnectDeviceInfo(
device_id=tank_data.device_id,
device_name=tank_data.device_name,
hardware_version=tank_data.hardware_version,
lte_version=tank_data.lte_version,
tank_size=tank_data.tank_size,
tank_size_unit=tank_data.tank_size_unit,
)
async def _async_update_data(self) -> Tank:
"""Fetch device state."""
try:
state = await self.api_client.async_get_tank_data()
except CentriConnectConnectionError as err:
raise UpdateFailed(f"Error communicating with device: {err}") from err
except CentriConnectError as err:
raise UpdateFailed(f"Unexpected response: {err}") from err
return state
@@ -1,37 +0,0 @@
"""Defines a base CentriConnect entity."""
from typing import TYPE_CHECKING
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import CentriConnectCoordinator
class CentriConnectBaseEntity(CoordinatorEntity[CentriConnectCoordinator]):
"""Defines a base CentriConnect entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: CentriConnectCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the CentriConnect entity."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
name=coordinator.device_info.device_name,
serial_number=coordinator.device_info.device_id,
hw_version=coordinator.device_info.hardware_version,
sw_version=coordinator.device_info.lte_version,
manufacturer="CentriConnect",
)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
self.entity_description = description
@@ -1,68 +0,0 @@
{
"entity": {
"sensor": {
"alert_status": {
"default": "mdi:alert-circle-outline",
"state": {
"critical_level": "mdi:alert-circle",
"low_level": "mdi:alert-circle-outline",
"no_alert": "mdi:check-circle-outline"
}
},
"altitude": {
"default": "mdi:altimeter"
},
"battery_voltage": {
"default": "mdi:car-battery"
},
"device_temperature": {
"default": "mdi:thermometer"
},
"last_post_time": {
"default": "mdi:clock-end"
},
"latitude": {
"default": "mdi:latitude"
},
"longitude": {
"default": "mdi:longitude"
},
"lte_signal_level": {
"default": "mdi:signal",
"range": {
"0": "mdi:signal-cellular-outline",
"25": "mdi:signal-cellular-1",
"50": "mdi:signal-cellular-2",
"75": "mdi:signal-cellular-3"
}
},
"lte_signal_strength": {
"default": "mdi:signal-variant"
},
"next_post_time": {
"default": "mdi:clock-start"
},
"solar_level": {
"default": "mdi:sun-wireless"
},
"solar_voltage": {
"default": "mdi:solar-power"
},
"tank_level": {
"default": "mdi:gauge",
"range": {
"0": "mdi:gauge-empty",
"25": "mdi:gauge-low",
"50": "mdi:gauge",
"75": "mdi:gauge-full"
}
},
"tank_remaining_volume": {
"default": "mdi:storage-tank-outline"
},
"tank_size": {
"default": "mdi:storage-tank"
}
}
}
}
@@ -1,11 +0,0 @@
{
"domain": "centriconnect",
"name": "CentriConnect/MyPropane",
"codeowners": ["@gresrun"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/centriconnect",
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["aiocentriconnect==0.2.3"]
}
@@ -1,78 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not provide an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism.
discovery-update-info:
status: exempt
comment: This is a cloud polling integration with no local discovery mechanism.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: This integration is not a hub and only represents a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No user-actionable repair scenarios identified for this integration.
stale-devices:
status: exempt
comment: Devices removed from account stop appearing in API responses and become unavailable.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
@@ -1,242 +0,0 @@
"""Sensor platform for CentriConnect/MyPropane API integration."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from homeassistant.components.sensor import (
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
UnitOfTemperature,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfElectricPotential,
UnitOfLength,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
from .entity import CentriConnectBaseEntity
# Coordinator is used to centralize the data updates.
PARALLEL_UPDATES = 0
_ALERT_STATUS_VALUES = {
"No Alert": "no_alert",
"Low Level": "low_level",
"Critical Level": "critical_level",
}
class CentriConnectSensorType(StrEnum):
"""Enumerates CentriConnect sensor types exposed by the device."""
ALERT_STATUS = "alert_status"
ALTITUDE = "altitude"
BATTERY_LEVEL = "battery_level"
BATTERY_VOLTAGE = "battery_voltage"
DEVICE_TEMPERATURE = "device_temperature"
LAST_POST_TIME = "last_post_time"
LATITUDE = "latitude"
LONGITUDE = "longitude"
LTE_SIGNAL_LEVEL = "lte_signal_level"
LTE_SIGNAL_STRENGTH = "lte_signal_strength"
NEXT_POST_TIME = "next_post_time"
SOLAR_LEVEL = "solar_level"
SOLAR_VOLTAGE = "solar_voltage"
TANK_LEVEL = "tank_level"
TANK_REMAINING_VOLUME = "tank_remaining_volume"
TANK_SIZE = "tank_size"
@dataclass(frozen=True, kw_only=True)
class CentriConnectSensorEntityDescription(SensorEntityDescription):
"""Description of a CentriConnect sensor entity."""
key: CentriConnectSensorType
value_fn: Callable[[CentriConnectCoordinator], StateType | datetime | None]
ENTITIES: tuple[CentriConnectSensorEntityDescription, ...] = (
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.ALERT_STATUS,
translation_key=CentriConnectSensorType.ALERT_STATUS,
device_class=SensorDeviceClass.ENUM,
options=list(_ALERT_STATUS_VALUES.values()),
value_fn=lambda coord: _ALERT_STATUS_VALUES.get(coord.data.alert_status),
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.ALTITUDE,
translation_key=CentriConnectSensorType.ALTITUDE,
native_unit_of_measurement=UnitOfLength.METERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DISTANCE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=2,
value_fn=lambda coord: coord.data.altitude,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.BATTERY_LEVEL,
translation_key=CentriConnectSensorType.BATTERY_LEVEL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda coord: coord.data.battery_level,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.BATTERY_VOLTAGE,
translation_key=CentriConnectSensorType.BATTERY_VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=0,
value_fn=lambda coord: coord.data.battery_voltage,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.DEVICE_TEMPERATURE,
translation_key=CentriConnectSensorType.DEVICE_TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=1,
value_fn=lambda coord: coord.data.device_temperature,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
translation_key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
value_fn=lambda coord: coord.data.lte_signal_level,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
translation_key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda coord: coord.data.lte_signal_strength,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.SOLAR_LEVEL,
translation_key=CentriConnectSensorType.SOLAR_LEVEL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=0,
value_fn=lambda coord: coord.data.solar_level,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.SOLAR_VOLTAGE,
translation_key=CentriConnectSensorType.SOLAR_VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
suggested_display_precision=0,
value_fn=lambda coord: coord.data.solar_voltage,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.TANK_LEVEL,
translation_key=CentriConnectSensorType.TANK_LEVEL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda coord: coord.data.tank_level,
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
native_unit_of_measurement=UnitOfVolume.GALLONS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=2,
value_fn=lambda coord: (
coord.data.tank_remaining_volume
if coord.device_info.tank_size_unit == "Gallons"
else None
),
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=2,
value_fn=lambda coord: (
coord.data.tank_remaining_volume
if coord.device_info.tank_size_unit == "Liters"
else None
),
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.TANK_SIZE,
translation_key=CentriConnectSensorType.TANK_SIZE,
native_unit_of_measurement=UnitOfVolume.GALLONS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=2,
value_fn=lambda coord: (
coord.device_info.tank_size
if (coord.device_info.tank_size_unit == "Gallons")
else None
),
),
CentriConnectSensorEntityDescription(
key=CentriConnectSensorType.TANK_SIZE,
translation_key=CentriConnectSensorType.TANK_SIZE,
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLUME_STORAGE,
suggested_display_precision=2,
value_fn=lambda coord: (
coord.device_info.tank_size
if (coord.device_info.tank_size_unit == "Liters")
else None
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: CentriConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up CentriConnect sensor entities from a config entry."""
async_add_entities(
CentriConnectSensor(entry.runtime_data, description)
for description in ENTITIES
if description.value_fn(entry.runtime_data) is not None
)
class CentriConnectSensor(CentriConnectBaseEntity, SensorEntity):
"""Representation of a CentriConnect sensor entity."""
entity_description: CentriConnectSensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator)
@@ -1,69 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"device_id": "Device ID",
"password": "Device Authentication Code",
"username": "User ID"
},
"data_description": {
"device_id": "Your CentriConnect/MyPropane device ID",
"password": "Your CentriConnect/MyPropane device authentication code",
"username": "Your CentriConnect/MyPropane user ID"
},
"description": "Enter your CentriConnect/MyPropane device credentials."
}
}
},
"entity": {
"sensor": {
"alert_status": {
"name": "Alert status",
"state": {
"critical_level": "Critical level",
"low_level": "Low level",
"no_alert": "No alert"
}
},
"altitude": {
"name": "Altitude"
},
"battery_voltage": {
"name": "Battery voltage"
},
"device_temperature": {
"name": "Device temperature"
},
"lte_signal_level": {
"name": "LTE signal level"
},
"lte_signal_strength": {
"name": "LTE signal strength"
},
"solar_level": {
"name": "Solar level"
},
"solar_voltage": {
"name": "Solar voltage"
},
"tank_level": {
"name": "Tank level"
},
"tank_remaining_volume": {
"name": "Tank remaining volume"
},
"tank_size": {
"name": "Tank size"
}
}
}
}
+6 -6
View File
@@ -8,10 +8,10 @@ import voluptuous as vol
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
repairs_flow_manager,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
from .const import DATA_CLOUD, DOMAIN
@@ -50,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
wait_task: asyncio.Task | None = None
_data: SubscriptionInfo | None = None
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
async def async_step_init(self, _: None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm_change_plan()
async def async_step_confirm_change_plan(
self,
user_input: dict[str, str] | None = None,
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
return await self.async_step_change_plan()
@@ -66,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
step_id="confirm_change_plan", data_schema=vol.Schema({})
)
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
async def async_step_change_plan(self, _: None = None) -> FlowResult:
"""Wait for the user to authorize the app installation."""
cloud = self.hass.data[DATA_CLOUD]
@@ -107,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
return self.async_external_step_done(next_step_id="complete")
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
async def async_step_complete(self, _: None = None) -> FlowResult:
"""Handle the final step of a fix flow."""
return self.async_create_entry(data={})
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
async def async_step_timeout(self, _: None = None) -> FlowResult:
"""Handle the final step of a fix flow."""
return self.async_abort(reason="operation_took_too_long")
@@ -32,7 +32,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
@dataclass
-220
View File
@@ -1,220 +0,0 @@
"""Platform for Control4 Covers (blinds and shades)."""
from datetime import timedelta
import logging
from typing import Any
from pyControl4.blind import C4Blind
from pyControl4.error_handling import C4Exception
from homeassistant.components.cover import (
ATTR_POSITION,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, get_items_of_category
from .const import CONTROL4_ENTITY_TYPE
from .director_utils import update_variables_for_config_entry
from .entity import Control4Entity
_LOGGER = logging.getLogger(__name__)
CONTROL4_CATEGORY = "blinds_shades"
CONTROL4_LEVEL = "Level"
CONTROL4_FULLY_CLOSED = "Fully Closed"
CONTROL4_FULLY_OPEN = "Fully Open"
CONTROL4_OPENING = "Opening"
CONTROL4_CLOSING = "Closing"
VARIABLES_OF_INTEREST = {
CONTROL4_LEVEL,
CONTROL4_FULLY_CLOSED,
CONTROL4_FULLY_OPEN,
CONTROL4_OPENING,
CONTROL4_CLOSING,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 covers from a config entry."""
runtime_data = entry.runtime_data
async def async_update_data() -> dict[int, dict[str, Any]]:
"""Fetch data from Control4 director for blinds."""
try:
return await update_variables_for_config_entry(
hass, entry, VARIABLES_OF_INTEREST
)
except C4Exception as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
hass,
_LOGGER,
name="cover",
update_method=async_update_data,
update_interval=timedelta(seconds=runtime_data.scan_interval),
config_entry=entry,
)
await coordinator.async_refresh()
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
entity_list = []
for item in items_of_category:
try:
if item["type"] != CONTROL4_ENTITY_TYPE:
continue
item_name = item["name"]
item_id = item["id"]
item_parent_id = item["parentId"]
item_manufacturer = None
item_device_name = None
item_model = None
for parent_item in items_of_category:
if parent_item["id"] == item_parent_id:
item_manufacturer = parent_item.get("manufacturer")
item_device_name = parent_item.get("roomName")
item_model = parent_item.get("model")
except KeyError:
_LOGGER.exception(
"Unknown device properties received from Control4: %s",
item,
)
continue
if item_id not in coordinator.data:
_LOGGER.warning(
"Couldn't get cover state data for %s (ID: %s), skipping setup",
item_name,
item_id,
)
continue
entity_list.append(
Control4Cover(
runtime_data,
coordinator,
item_name,
item_id,
item_device_name,
item_manufacturer,
item_model,
item_parent_id,
)
)
async_add_entities(entity_list)
class Control4Cover(Control4Entity, CoverEntity):
"""Control4 cover entity."""
_attr_has_entity_name = True
_attr_translation_key = "blind"
_attr_device_class = CoverDeviceClass.SHADE
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._cover_data is not None
def _create_api_object(self) -> C4Blind:
"""Create a pyControl4 device object.
This exists so the director token used is always the latest one,
without needing to re-init the entire entity.
"""
return C4Blind(self.runtime_data.director, self._idx)
@property
def _cover_data(self) -> dict[str, Any] | None:
"""Return the cover data from the coordinator."""
return self.coordinator.data.get(self._idx)
@property
def current_cover_position(self) -> int | None:
"""Return current position of cover (0 closed, 100 open)."""
data = self._cover_data
if data is None:
return None
level = data.get(CONTROL4_LEVEL)
if level is None:
return None
return int(level)
@property
def is_closed(self) -> bool | None:
"""Return if the cover is closed."""
data = self._cover_data
if data is None:
return None
if (fully_closed := data.get(CONTROL4_FULLY_CLOSED)) is not None:
return bool(fully_closed)
position = self.current_cover_position
if position is None:
return None
return position == 0
@property
def is_opening(self) -> bool | None:
"""Return if the cover is opening."""
data = self._cover_data
if data is None:
return None
opening = data.get(CONTROL4_OPENING)
if opening is None:
return None
return bool(opening)
@property
def is_closing(self) -> bool | None:
"""Return if the cover is closing."""
data = self._cover_data
if data is None:
return None
closing = data.get(CONTROL4_CLOSING)
if closing is None:
return None
return bool(closing)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
c4_blind = self._create_api_object()
await c4_blind.open()
await self.coordinator.async_request_refresh()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
c4_blind = self._create_api_object()
await c4_blind.close()
await self.coordinator.async_request_refresh()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
c4_blind = self._create_api_object()
await c4_blind.stop()
await self.coordinator.async_request_refresh()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
c4_blind = self._create_api_object()
await c4_blind.setLevelTarget(kwargs[ATTR_POSITION])
await self.coordinator.async_request_refresh()
@@ -30,12 +30,6 @@ 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,
@@ -109,34 +103,6 @@ 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.
@@ -1,27 +0,0 @@
"""Diagnostics support for the Data Grand Lyon integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .coordinator import DataGrandLyonConfigEntry
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
"coordinator_data": {
subentry_id: [asdict(passage) for passage in passages]
for subentry_id, passages in coordinator.data.items()
},
}
@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
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: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -21,16 +20,6 @@
"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 RS-232 integration."""
"""The Denon RS232 integration."""
from denon_rs232 import DenonReceiver, ReceiverState
from denon_rs232.models import MODELS
@@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
"""Set up Denon RS-232 from a config entry."""
"""Set up Denon RS232 from a config entry."""
port = entry.data[CONF_DEVICE]
model = MODELS[entry.data[CONF_MODEL]]
receiver = DenonReceiver(port, model=model)
@@ -1,4 +1,4 @@
"""Config flow for the Denon RS-232 integration."""
"""Config flow for the Denon RS232 integration."""
from typing import Any
@@ -63,7 +63,7 @@ async def _async_attempt_connect(port: str, model_key: str) -> str | None:
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Denon RS-232."""
"""Handle a config flow for Denon RS232."""
VERSION = 1
@@ -1,4 +1,4 @@
"""Constants for the Denon RS-232 integration."""
"""Constants for the Denon RS232 integration."""
import logging
@@ -1,6 +1,6 @@
{
"domain": "denon_rs232",
"name": "Denon RS-232",
"name": "Denon RS232",
"codeowners": ["@balloob"],
"config_flow": true,
"dependencies": ["usb"],
@@ -1,4 +1,4 @@
"""Media player platform for the Denon RS-232 integration."""
"""Media player platform for the Denon RS232 integration."""
from typing import Literal, cast
@@ -77,7 +77,7 @@ async def async_setup_entry(
config_entry: DenonRS232ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Denon RS-232 media player."""
"""Set up the Denon RS232 media player."""
receiver = config_entry.runtime_data
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
@@ -94,7 +94,7 @@ async def async_setup_entry(
class DenonRS232MediaPlayer(MediaPlayerEntity):
"""Representation of a Denon receiver controlled over RS-232."""
"""Representation of a Denon receiver controlled over RS232."""
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_has_entity_name = True
+4 -3
View File
@@ -2,7 +2,8 @@
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -16,13 +17,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
self.hass.config_entries.async_schedule_reload(self.entry_id)
+3 -15
View File
@@ -1,34 +1,22 @@
"""The Duco integration."""
import re
from duco_connectivity import DucoClient
from duco import DucoClient, build_ssl_context
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .coordinator import DucoConfigEntry, DucoCoordinator
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
"""Set up Duco from a config entry."""
# Remove entity registry entries for the temperature and box_temperature
# sensors that were removed when migrating to python-duco-connectivity.
entity_registry = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_registry, entry.entry_id
):
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
entity_registry.async_remove(entity_entry.entity_id)
ssl_context = await hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(hass),
host=entry.data[CONF_HOST],
ssl_context=ssl_context,
)
coordinator = DucoCoordinator(hass, entry, client)
+4 -2
View File
@@ -3,8 +3,8 @@
import logging
from typing import Any
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco import DucoClient, build_ssl_context
from duco.exceptions import DucoConnectionError, DucoError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -158,9 +158,11 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
Returns a tuple of (box_name, mac_address).
"""
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
client = DucoClient(
session=async_get_clientsession(self.hass),
host=host,
ssl_context=ssl_context,
)
board_info = await client.async_get_board_info()
lan_info = await client.async_get_lan_info()
+3 -3
View File
@@ -3,9 +3,9 @@
from dataclasses import dataclass
import logging
from duco_connectivity import DucoClient
from duco_connectivity.exceptions import DucoConnectionError, DucoError
from duco_connectivity.models import BoardInfo, Node
from duco import DucoClient
from duco.exceptions import DucoConnectionError, DucoError
from duco.models import BoardInfo, Node
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+2 -2
View File
@@ -3,7 +3,7 @@
from dataclasses import asdict
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco.exceptions import DucoConnectionError
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
api_info_obj = await coordinator.client.async_get_api_info()
lan_info = await coordinator.client.async_get_lan_info()
duco_diags = await coordinator.client.async_get_diagnostics()
write_remaining = await coordinator.client.async_get_write_requests_remaining()
write_remaining = await coordinator.client.async_get_write_req_remaining()
except DucoConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
+1 -1
View File
@@ -1,6 +1,6 @@
"""Base entity for the Duco integration."""
from duco_connectivity.models import Node
from duco.models import Node
from homeassistant.const import ATTR_VIA_DEVICE
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+2 -2
View File
@@ -2,8 +2,8 @@
import logging
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
from duco_connectivity.models import Node, NodeType, VentilationState
from duco.exceptions import DucoError, DucoRateLimitError
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
+2 -2
View File
@@ -11,9 +11,9 @@
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco_connectivity"],
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-connectivity==0.2.0"],
"requirements": ["python-duco-client==0.5.0"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
+23 -9
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
from datetime import datetime
import logging
from duco_connectivity.models import Node, NodeType, VentilationState
from duco.models import Node, NodeType, VentilationState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -18,6 +18,7 @@ from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
@@ -53,18 +54,20 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
key="ventilation_state",
translation_key="ventilation_state",
device_class=SensorDeviceClass.ENUM,
options=[
state.lower()
for state in VentilationState
if state != VentilationState.UNKNOWN
],
options=[s.lower() for s in VentilationState],
value_fn=lambda node: (
node.ventilation.state.lower()
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
else None
node.ventilation.state.lower() if node.ventilation else None
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
),
DucoSensorEntityDescription(
key="target_flow_level",
translation_key="target_flow_level",
@@ -89,6 +92,17 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
),
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="box_temperature",
translation_key="box_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda node: node.sensor.temp if node.sensor else None,
node_types=(NodeType.BOX,),
),
DucoSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
@@ -47,6 +47,9 @@
}
},
"sensor": {
"box_temperature": {
"name": "Box temperature"
},
"iaq_co2": {
"name": "CO2 air quality index"
},
@@ -2,7 +2,7 @@
from typing import Any
from duco_connectivity.exceptions import DucoConnectionError
from duco.exceptions import DucoConnectionError
from homeassistant.components import system_health
from homeassistant.core import HomeAssistant, callback
@@ -24,9 +24,7 @@ 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_requests_remaining()
)
return await config_entry.runtime_data.client.async_get_write_req_remaining()
except DucoConnectionError:
return {"type": "failed", "error": "unreachable"}
@@ -76,10 +76,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
)
except EasyEnergyConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
) from err
raise UpdateFailed("Error communicating with easyEnergy API") from err
return EasyEnergyData(
energy_today=energy_today,
@@ -1,33 +1,12 @@
{
"entity": {
"sensor": {
"average_price": {
"default": "mdi:cash-multiple"
},
"current_hour_price": {
"default": "mdi:cash"
},
"highest_price_time": {
"default": "mdi:clock-outline"
},
"hours_priced_equal_or_higher": {
"default": "mdi:clock"
},
"hours_priced_equal_or_lower": {
"default": "mdi:clock"
},
"lowest_price_time": {
"default": "mdi:clock-outline"
},
"max_price": {
"default": "mdi:cash-plus"
},
"min_price": {
"default": "mdi:cash-minus"
},
"next_hour_price": {
"default": "mdi:cash"
},
"percentage_of_max": {
"default": "mdi:percent"
}
@@ -31,9 +31,6 @@ from .coordinator import (
EasyEnergyDataUpdateCoordinator,
)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EasyEnergySensorEntityDescription(SensorEntityDescription):
@@ -47,9 +47,6 @@
}
},
"exceptions": {
"connection_error": {
"message": "Error communicating with the easyEnergy API."
},
"invalid_date": {
"message": "Invalid date provided. Got {date}"
}
@@ -17,8 +17,6 @@ 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,8 +38,6 @@ 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__)
-4
View File
@@ -163,9 +163,6 @@ 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."""
@@ -486,7 +483,6 @@ 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,
}
)
+5 -4
View File
@@ -4,7 +4,8 @@ from typing import cast
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from .manager import async_replace_device
@@ -42,7 +43,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -51,7 +52,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_migrate(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the migrate step of a fix flow."""
if user_input is None:
return self.async_show_form(
@@ -65,7 +66,7 @@ class DeviceConflictRepair(ESPHomeRepair):
async def async_step_manual(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the manual step of a fix flow."""
if user_input is None:
return self.async_show_form(
@@ -16,7 +16,6 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.CLIMATE,
Platform.NUMBER,
Platform.SENSOR,
]
@@ -4,17 +4,6 @@
"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"
}
}
}
}
@@ -1,127 +0,0 @@
"""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,17 +35,6 @@
"sync_time": {
"name": "Sync time"
}
},
"number": {
"comfort_setpoint": {
"name": "Comfort setpoint"
},
"eco_setpoint": {
"name": "Eco setpoint"
},
"offset": {
"name": "Setpoint offset"
}
}
}
}
+17 -2
View File
@@ -1,4 +1,9 @@
{
"common": {
"data_description_host": "The hostname or IP address of your FRITZ!Box router.",
"data_description_password": "Password for the user to connect Home Assistant to your FRITZ!Box.",
"data_description_username": "Name of the user to connect Home Assistant to your FRITZ!Box."
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -20,6 +25,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Do you want to set up {name}?"
},
"reauth_confirm": {
@@ -27,6 +36,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Update your login information for {name}."
},
"reconfigure": {
@@ -34,7 +47,7 @@
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router."
"host": "[%key:component::fritzbox::common::data_description_host%]"
},
"description": "Update your configuration information for {name}."
},
@@ -45,7 +58,9 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router."
"host": "[%key:component::fritzbox::common::data_description_host%]",
"password": "[%key:component::fritzbox::common::data_description_password%]",
"username": "[%key:component::fritzbox::common::data_description_username%]"
},
"description": "Enter your FRITZ!Box information."
}
@@ -12,7 +12,6 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_PRESET_MODE,
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
PRESET_NONE,
@@ -452,9 +451,6 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
self._target_temp = temperature
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
await self.async_set_hvac_mode(hvac_mode)
return
await self._async_control_heating(force=True)
self.async_write_ha_state()
@@ -1201,17 +1201,6 @@ class TemperatureSettingTrait(_Trait):
preset_to_google = {climate.PRESET_ECO: "eco"}
google_to_preset = {value: key for key, value in preset_to_google.items()}
action_to_google = {
climate.HVACAction.OFF: "off",
climate.HVACAction.HEATING: "heat",
climate.HVACAction.DEFROSTING: "heat",
climate.HVACAction.PREHEATING: "heat",
climate.HVACAction.COOLING: "cool",
climate.HVACAction.DRYING: "dry",
climate.HVACAction.FAN: "fan-only",
climate.HVACAction.IDLE: "none",
}
@staticmethod
def supported(domain, features, device_class, _):
"""Test if state is supported."""
@@ -1295,11 +1284,6 @@ class TemperatureSettingTrait(_Trait):
else:
response["thermostatMode"] = self.hvac_to_google.get(operation, "none")
if (
action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION))
) is not None:
response["activeThermostatMode"] = action
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
if current_temp is not None:
response["thermostatTemperatureAmbient"] = round(
@@ -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")
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
if isinstance(value, list):
return [_escape_decode(item) for item in value]
if isinstance(value, dict):
+8 -7
View File
@@ -8,9 +8,10 @@ from aiohasupervisor import SupervisorError
from aiohasupervisor.models import ContextType
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.components.repairs import RepairsFlow
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from . import get_addons_list
from .const import (
@@ -76,7 +77,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
return placeholders or None
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
"""Return form for suggestion."""
return self.async_show_form(
step_id=suggestion.key,
@@ -85,7 +86,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
last_step=True,
)
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
async def async_step_init(self, _: None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
# Out of sync with supervisor, issue is resolved or not fixable. Remove it
if not self.issue or not self.issue.suggestions:
@@ -107,7 +108,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
# Always show a form for one suggestion to explain to user what's happening
return self._async_form_for_suggestion(self.issue.suggestions[0])
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
async def async_step_fix_menu(self, _: None = None) -> FlowResult:
"""Show the fix menu."""
assert self.issue
@@ -119,7 +120,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
async def _async_step_apply_suggestion(
self, suggestion: Suggestion, confirmed: bool = False
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle applying a suggestion as a flow step. Optionally request confirmation."""
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
return self._async_form_for_suggestion(suggestion)
@@ -136,13 +137,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
suggestion: Suggestion,
) -> Callable[
[SupervisorIssueRepairFlow, dict[str, str] | None],
Coroutine[Any, Any, RepairsFlowResult],
Coroutine[Any, Any, FlowResult],
]:
"""Generate a step handler for a suggestion."""
async def _async_step(
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle a flow step for a suggestion."""
return await self._async_step_apply_suggestion(
suggestion, confirmed=user_input is not None
+7 -36
View File
@@ -7,7 +7,6 @@ from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
Folder,
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
@@ -71,31 +70,6 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
# for backwards compatibility with existing automations.
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
"""Map legacy aliases used by both partial backup and partial restore handlers."""
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
if ATTR_FOLDERS in data:
folders: set[Any] = set(data[ATTR_FOLDERS])
if LEGACY_FOLDER_HOMEASSISTANT in folders:
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
if data.get(ATTR_HOMEASSISTANT) is False:
raise ServiceValidationError(
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
)
data[ATTR_HOMEASSISTANT] = True
if folders:
data[ATTR_FOLDERS] = folders
else:
data.pop(ATTR_FOLDERS)
return data
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
@@ -139,10 +113,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -165,10 +136,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list,
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
vol.Unique(),
vol.Coerce(set),
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
@@ -375,7 +343,9 @@ def async_register_backup_restore_services(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = _normalize_partial_options_data(service.data.copy())
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
@@ -422,7 +392,8 @@ def async_register_backup_restore_services(
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
data = _normalize_partial_options_data(data)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
@@ -4,7 +4,6 @@ import logging
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
from pycec.const import (
CMD_STANDBY,
KEY_BACKWARD,
KEY_FORWARD,
KEY_MUTE_TOGGLE,
@@ -94,7 +93,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
async def async_turn_off(self) -> None:
"""Turn device off."""
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
self._device.turn_off()
self._attr_state = MediaPlayerState.OFF
self.async_write_ha_state()
+2 -3
View File
@@ -3,8 +3,7 @@
import logging
from typing import Any
from pycec.commands import CecCommand
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
from pycec.const import POWER_OFF, POWER_ON
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import HomeAssistant
@@ -51,7 +50,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn device off."""
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
self._device.turn_off()
self._attr_is_on = False
self.async_write_ha_state()
@@ -1,11 +1,8 @@
"""Repairs for Home Assistant."""
from homeassistant.components.repairs import (
ConfirmRepairFlow,
RepairsFlow,
RepairsFlowResult,
)
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN
@@ -21,7 +18,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -31,7 +28,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the confirm step of a fix flow."""
entries = self.hass.config_entries.async_entries(self.domain)
for entry in entries:
@@ -40,7 +37,7 @@ class IntegrationNotFoundFlow(RepairsFlow):
async def async_step_ignore(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_get(self.hass).async_ignore(
DOMAIN, f"integration_not_found.{self.domain}", True
@@ -61,7 +58,7 @@ class OrphanedConfigEntryFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
@@ -71,14 +68,14 @@ class OrphanedConfigEntryFlow(RepairsFlow):
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""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
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the ignore step of a fix flow."""
ir.async_get(self.hass).async_ignore(
DOMAIN, f"orphaned_ignored_entry.{self.entry_id}", True
@@ -1,13 +0,0 @@
"""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,12 +2,7 @@
from typing import Any
from homematicip.base.enums import (
BinaryBehaviorType,
LockState,
SmokeDetectorAlarmType,
WindowState,
)
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
from homematicip.base.functionalChannels import MultiModeInputChannel
from homematicip.device import (
AccelerationSensor,
@@ -357,22 +352,7 @@ class HomematicipFullFlushLockControllerLocked(
@property
def is_on(self) -> bool:
"""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.
"""
"""Return true if the controlled lock is locked."""
channel = _get_channel_by_role(
self._device,
"MULTI_MODE_LOCK_INPUT_CHANNEL",
@@ -381,15 +361,7 @@ class HomematicipFullFlushLockControllerLocked(
if channel is None:
return False
lock_state = getattr(channel, "lockState", None)
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
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
class HomematicipFullFlushLockControllerGlassBreak(
@@ -1,9 +1,11 @@
"""Repairs for HomeWizard integration."""
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
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
@@ -17,14 +19,14 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
@@ -36,7 +38,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
async def async_step_authorize(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
) -> FlowResult:
"""Handle the authorize step of a fix flow."""
ip_address = self.entry.data[CONF_IP_ADDRESS]
@@ -3,7 +3,6 @@
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
@@ -12,6 +11,7 @@ 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(
CODES.load_command, "turn_on"
COMMANDS.load_command, "turn_on"
)
try:
transmitters = async_get_transmitters(
@@ -2,7 +2,7 @@
from typing import Any
from rf_protocols.codes.honeywell.string_lights import CODES
from rf_protocols import get_codes
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.components.radio_frequency import async_send_command
@@ -16,6 +16,8 @@ from .entity import HoneywellStringLightsEntity
PARALLEL_UPDATES = 1
COMMANDS = get_codes("honeywell/string_lights")
async def async_setup_entry(
hass: HomeAssistant,
@@ -55,7 +57,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 CODES.async_load_command(name)
command = await COMMANDS.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==3.0.0"]
"requirements": ["rf-protocols==2.2.0"]
}
+1 -6
View File
@@ -3,9 +3,4 @@
from datetime import timedelta
DOMAIN = "iaqualink"
UPDATE_INTERVAL_BY_SYSTEM_TYPE: dict[str, timedelta] = {
"iaqua": timedelta(seconds=15),
"exo": timedelta(seconds=60),
}
UPDATE_INTERVAL_DEFAULT = timedelta(seconds=30)
UPDATE_INTERVAL = timedelta(seconds=15)
@@ -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_BY_SYSTEM_TYPE, UPDATE_INTERVAL_DEFAULT
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -26,15 +26,12 @@ 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
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["imgw_pib==2.1.2"]
"requirements": ["imgw_pib==2.1.1"]
}
@@ -27,25 +27,13 @@ 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,6 +33,14 @@ 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."""
@@ -88,7 +96,12 @@ 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."""
return await self.api.set_data(sensor_key, value)
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
async def async_switch_energy_mode(
self, target_mode: IndevoltEnergyMode, refresh: bool = True
@@ -112,9 +125,15 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Switch energy mode if required
if current_mode != target_mode:
success = await self.async_push_data(
IndevoltConfig.WRITE_ENERGY_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
if not success:
raise HomeAssistantError(
@@ -1,16 +1,4 @@
{
"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.2"]
"requirements": ["indevolt-api==1.7.1"]
}
+1 -6
View File
@@ -17,7 +17,6 @@ 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
@@ -139,8 +138,4 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_error",
translation_placeholders={"name": str(self.name)},
)
raise HomeAssistantError(f"Failed to set value {int_value} for {self.name}")
@@ -46,13 +46,13 @@ rules:
discovery:
status: exempt
comment: Integration does not support network discovery
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
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:
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: done
icon-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues:
status: exempt
@@ -73,4 +73,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo
+1 -6
View File
@@ -11,7 +11,6 @@ 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
@@ -109,8 +108,4 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_error",
translation_placeholders={"name": str(self.name)},
)
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")
+14 -2
View File
@@ -1,7 +1,7 @@
"""Sensor platform for Indevolt integration."""
from dataclasses import dataclass, field
from typing import Final, cast
from typing import Final
from indevolt_api import (
IndevoltBattery,
@@ -100,6 +100,7 @@ SENSORS: Final = (
),
IndevoltSensorEntityDescription(
key=IndevoltSystem.BYPASS_POWER,
generation=(2,),
translation_key="bypass_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
@@ -115,6 +116,7 @@ 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,
@@ -122,6 +124,7 @@ 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,
@@ -129,6 +132,7 @@ 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,
@@ -136,6 +140,7 @@ 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,
@@ -143,6 +148,7 @@ 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,
@@ -150,6 +156,7 @@ 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,
@@ -157,6 +164,7 @@ 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,
@@ -244,6 +252,7 @@ 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,
@@ -252,6 +261,7 @@ 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,
@@ -268,6 +278,7 @@ 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,
@@ -276,6 +287,7 @@ 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,
@@ -768,4 +780,4 @@ class IndevoltSensorEntity(IndevoltEntity, SensorEntity):
if self.entity_description.device_class == SensorDeviceClass.ENUM:
return self.entity_description.state_mapping.get(raw_value)
return cast(str | int | float, raw_value)
return raw_value
@@ -354,9 +354,6 @@
},
"soc_below_minimum": {
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
},
"write_error": {
"message": "Cannot update value for {name}"
}
},
"services": {
+1 -6
View File
@@ -15,7 +15,6 @@ 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
@@ -129,8 +128,4 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="write_error",
translation_placeholders={"name": str(self.name)},
)
raise HomeAssistantError(f"Failed to set value {value} for {self.name}")
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/infrared",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["infrared-protocols==5.1.0"]
"requirements": ["infrared-protocols==3.5.0"]
}
+1 -1
View File
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool:
@@ -10,4 +10,3 @@ PORT = 8081
POLL_INTERVAL = 15
DEFAULT_SSL = False
DEFAULT_SSL_VERIFY = False
REFRESH_DELAY = 0.5
@@ -27,11 +27,6 @@
"last_motion": {
"default": "mdi:motion-sensor"
}
},
"switch": {
"disable_screensaver": {
"default": "mdi:power-sleep"
}
}
}
}
@@ -66,11 +66,6 @@
"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