mirror of
https://github.com/home-assistant/core.git
synced 2026-05-13 22:41:48 +00:00
Compare commits
121 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3b2be7e86 | |||
| a2131c0d45 | |||
| b179d71658 | |||
| 070ef8f0b0 | |||
| aaeb55b132 | |||
| 1f5cb05f50 | |||
| cee87ed1f5 | |||
| e2ae9c1b95 | |||
| 8b257cdd6c | |||
| f756392b6a | |||
| 894ee88033 | |||
| d5d56e6e23 | |||
| a19a1ec6e8 | |||
| b98015dc76 | |||
| 4112b2af07 | |||
| 944c0d7ed2 | |||
| a471f7059f | |||
| cd1d4244ae | |||
| 573409dcbf | |||
| 6ec70734c1 | |||
| adf6213c9f | |||
| e925672bb6 | |||
| 15c5e257f5 | |||
| 8396964023 | |||
| 09a08011d6 | |||
| 891e0aebb0 | |||
| ca9a7f6051 | |||
| 24dc206462 | |||
| 60e4f924a0 | |||
| 339703ca04 | |||
| 362cba91fb | |||
| a215b82bd9 | |||
| 3393598d91 | |||
| 676df1d2b2 | |||
| 36cc629faf | |||
| 99b1e7c229 | |||
| cfdb00bf36 | |||
| 9b8c81cba1 | |||
| 095cf07f43 | |||
| b275791a71 | |||
| e7dccd3ad3 | |||
| adab0d6486 | |||
| aad964889f | |||
| 9200658526 | |||
| 68f10249a5 | |||
| b5ee78aeac | |||
| 86a967ee7b | |||
| eeca75b937 | |||
| ce6b6601fa | |||
| 4641c829ca | |||
| 56fbd096e2 | |||
| c071c08f86 | |||
| e47c152222 | |||
| 8232415fd5 | |||
| dcc95328ec | |||
| 85faab5d5d | |||
| bacb8a8fea | |||
| c9926915ff | |||
| 0772034d9d | |||
| 8cfdc52762 | |||
| 738b9936d9 | |||
| b3bb5c9abc | |||
| 3149da12a4 | |||
| e2805e4489 | |||
| 14a8ef6e48 | |||
| 015fc5809a | |||
| 2e4f4040c7 | |||
| 095de73a53 | |||
| 7dca14e78a | |||
| 0a974cbc7a | |||
| 2e37a0bba6 | |||
| 7e2ec795d6 | |||
| 7ba7700d5e | |||
| 261ca2dd9a | |||
| 284478f620 | |||
| 62ac3f9834 | |||
| 3bf57ae9cd | |||
| ed0abfb238 | |||
| 0789eb0db6 | |||
| 980d43accc | |||
| 6d8b010245 | |||
| dc9eba372a | |||
| 20827b66d9 | |||
| a43ab34302 | |||
| b14e863877 | |||
| af41b704d5 | |||
| d5f2cd8b17 | |||
| f96afda959 | |||
| 94bf13c6bb | |||
| b7dca79743 | |||
| df84d7a32d | |||
| c217acd7ab | |||
| f008f1501f | |||
| 739a5780b7 | |||
| 0ef221611a | |||
| 59e04c2169 | |||
| 5b0bf09fdc | |||
| c07d176467 | |||
| c39f0127ca | |||
| cdf5d39f57 | |||
| 90b6aa4d91 | |||
| f8ebc6c1e2 | |||
| e4b4503c10 | |||
| 7db1c855c1 | |||
| aa45f90a87 | |||
| cd945a42e6 | |||
| afc97268de | |||
| 497faeb103 | |||
| 84625678d3 | |||
| 95daee9f07 | |||
| ff1552e317 | |||
| ff6b69c929 | |||
| 2a74d5a81c | |||
| 52237247ae | |||
| 62d958682a | |||
| b2dad41d35 | |||
| 83c5dbb111 | |||
| cf73ef8a20 | |||
| 6555db12b1 | |||
| 20b81e9c74 | |||
| 51d004a5bb |
@@ -30,6 +30,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ jobs:
|
||||
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: "v2.5.3"
|
||||
|
||||
|
||||
@@ -853,7 +853,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
mypy homeassistant pylint
|
||||
mypy --num-workers=4 homeassistant pylint
|
||||
- name: Run mypy (partially)
|
||||
if: needs.info.outputs.test_full_suite == 'false'
|
||||
shell: bash
|
||||
@@ -862,7 +862,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
mypy $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
mypy --num-workers=4 $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
|
||||
|
||||
prepare-pytest-full:
|
||||
name: Split tests for full run
|
||||
|
||||
@@ -139,6 +139,7 @@ homeassistant.components.cambridge_audio.*
|
||||
homeassistant.components.camera.*
|
||||
homeassistant.components.canary.*
|
||||
homeassistant.components.casper_glow.*
|
||||
homeassistant.components.centriconnect.*
|
||||
homeassistant.components.cert_expiry.*
|
||||
homeassistant.components.clickatell.*
|
||||
homeassistant.components.clicksend.*
|
||||
@@ -296,6 +297,7 @@ homeassistant.components.imap.*
|
||||
homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.indevolt.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
@@ -421,6 +423,7 @@ homeassistant.components.opower.*
|
||||
homeassistant.components.oralb.*
|
||||
homeassistant.components.otbr.*
|
||||
homeassistant.components.otp.*
|
||||
homeassistant.components.ouman_eh_800.*
|
||||
homeassistant.components.overkiz.*
|
||||
homeassistant.components.overseerr.*
|
||||
homeassistant.components.p1_monitor.*
|
||||
|
||||
@@ -20,6 +20,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When writing or modifying tests, ensure all test function parameters have type annotations.
|
||||
- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
|
||||
- Prefer `@pytest.mark.usefixtures` over arguments, if the argument is not going to be used.
|
||||
- Avoid using conditions/branching in tests. Instead, either split tests or adjust the test parametrization to cover all cases without branching.
|
||||
- If multiple tests share most of their code, use `pytest.mark.parametrize` to merge them into a single parameterized test instead of duplicating the body.
|
||||
|
||||
|
||||
Generated
+7
@@ -196,6 +196,7 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/autoskope/ @mcisk
|
||||
/tests/components/autoskope/ @mcisk
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/tests/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @ricohageman
|
||||
/tests/components/awair/ @ahayworth @ricohageman
|
||||
/homeassistant/components/aws_s3/ @tomasbedrich
|
||||
@@ -288,6 +289,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/cast/ @emontnemery
|
||||
/homeassistant/components/ccm15/ @ocalvo
|
||||
/tests/components/ccm15/ @ocalvo
|
||||
/homeassistant/components/centriconnect/ @gresrun
|
||||
/tests/components/centriconnect/ @gresrun
|
||||
/homeassistant/components/cert_expiry/ @jjlawren
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
@@ -1302,6 +1305,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/osoenergy/ @osohotwateriot
|
||||
/homeassistant/components/otbr/ @home-assistant/core
|
||||
/tests/components/otbr/ @home-assistant/core
|
||||
/homeassistant/components/ouman_eh_800/ @Markus98
|
||||
/tests/components/ouman_eh_800/ @Markus98
|
||||
/homeassistant/components/ourgroceries/ @OnFreund
|
||||
/tests/components/ourgroceries/ @OnFreund
|
||||
/homeassistant/components/overkiz/ @imicknl
|
||||
@@ -2023,6 +2028,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/xiaomi_miio/ @rytilahti @syssi @starkillerOG
|
||||
/homeassistant/components/xiaomi_tv/ @simse
|
||||
/homeassistant/components/xmpp/ @fabaff @flowolf
|
||||
/homeassistant/components/xthings_cloud/ @XthingsJacobs
|
||||
/tests/components/xthings_cloud/ @XthingsJacobs
|
||||
/homeassistant/components/yale/ @bdraco
|
||||
/tests/components/yale/ @bdraco
|
||||
/homeassistant/components/yale_smart_alarm/ @gjohansson-ST
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "mitsubishi",
|
||||
"name": "Mitsubishi",
|
||||
"integrations": ["melcloud", "mitsubishi_comfort"]
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/acer_projector",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["serialx==1.7.1"]
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ class AemetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): str,
|
||||
vol.Optional(
|
||||
CONF_LATITUDE, default=self.hass.config.latitude
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: todo
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: done
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: The ozone sensor can still use the ozone device class.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
stale-devices: todo
|
||||
repair-issues: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -46,6 +46,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"radius": "Station radius (miles)"
|
||||
},
|
||||
"data_description": {
|
||||
"radius": "The radius in miles around your location to search for reporting stations."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
@@ -11,7 +12,12 @@ from uuid import uuid4
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components import event
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON
|
||||
from homeassistant.const import (
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
@@ -51,6 +57,25 @@ DEFAULT_TIMEOUT = 10
|
||||
TO_REDACT = {"correlationToken", "token"}
|
||||
|
||||
|
||||
def valid_doorbell_timestamp(entity_id: str, event_state: str) -> bool:
|
||||
"""Check if doorbell event timestamp is valid."""
|
||||
if event_state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
try:
|
||||
timestamp = datetime.fromisoformat(event_state)
|
||||
except ValueError:
|
||||
_LOGGER.debug(
|
||||
"Unable to parse ISO timestamp from state for %s. Got %s",
|
||||
entity_id,
|
||||
event_state,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if (dt_util.utcnow() - timestamp) < timedelta(seconds=30):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class AlexaDirective:
|
||||
"""An incoming Alexa directive."""
|
||||
|
||||
@@ -315,9 +340,17 @@ async def async_enable_proactive_mode(
|
||||
|
||||
if should_doorbell:
|
||||
old_state = data["old_state"]
|
||||
if new_state.domain == event.DOMAIN or (
|
||||
if (
|
||||
new_state.domain == event.DOMAIN
|
||||
and valid_doorbell_timestamp(new_state.entity_id, new_state.state)
|
||||
and (old_state is None or old_state.state != STATE_UNAVAILABLE)
|
||||
and (old_state is None or old_state.state != new_state.state)
|
||||
) or (
|
||||
new_state.state == STATE_ON
|
||||
and (old_state is None or old_state.state != STATE_ON)
|
||||
and (
|
||||
old_state is None
|
||||
or old_state.state not in (STATE_ON, STATE_UNAVAILABLE)
|
||||
)
|
||||
):
|
||||
await async_send_doorbell_event_message(
|
||||
hass, smart_home_config, alexa_changed_entity
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==13.4.3"]
|
||||
"requirements": ["aioamazondevices==13.5.0"]
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import anthropic
|
||||
from anthropic.resources.messages.messages import DEPRECATED_MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -41,9 +40,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
self._current_subentry_id = None
|
||||
self._model_list_cache = None
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str]
|
||||
) -> data_entry_flow.FlowResult:
|
||||
async def async_step_init(self, user_input: dict[str, str]) -> RepairsFlowResult:
|
||||
"""Handle the steps of a fix flow."""
|
||||
if user_input.get(CONF_CHAT_MODEL):
|
||||
self._async_update_current_subentry(user_input)
|
||||
|
||||
@@ -5,8 +5,7 @@ from typing import cast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name")
|
||||
@@ -21,14 +20,14 @@ class AssistInProgressDeprecatedRepairFlow(RepairsFlow):
|
||||
raise ValueError("Missing data")
|
||||
self._data = data
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_disable_entity()
|
||||
|
||||
async def async_step_confirm_disable_entity(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
"""The avea component."""
|
||||
"""The Avea integration."""
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Unload an Avea config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Config flow for Avea."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import avea
|
||||
from bleak.exc import BleakError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
|
||||
from .const import AVEA_SERVICE_UUID, DOMAIN, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str | None:
|
||||
"""Return a valid Avea name."""
|
||||
if not name or name == UNKNOWN_NAME:
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _validate_device(discovery_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Validate the device is reachable and return a title for it."""
|
||||
bulb = avea.Bulb(discovery_info.device)
|
||||
|
||||
try:
|
||||
if not bulb.connect():
|
||||
raise CannotConnect
|
||||
|
||||
try:
|
||||
name = bulb.get_name()
|
||||
except BleakError, OSError, RuntimeError:
|
||||
_LOGGER.debug(
|
||||
"Failed to get name for Avea device %s",
|
||||
discovery_info.address,
|
||||
exc_info=True,
|
||||
)
|
||||
name = None
|
||||
brightness = bulb.get_brightness()
|
||||
except (BleakError, OSError, RuntimeError) as err:
|
||||
raise CannotConnect from err
|
||||
finally:
|
||||
with suppress(BleakError, OSError, RuntimeError):
|
||||
bulb.close()
|
||||
|
||||
if brightness is None:
|
||||
raise CannotConnect
|
||||
|
||||
return (
|
||||
_normalize_name(name)
|
||||
or _normalize_name(discovery_info.name)
|
||||
or discovery_info.address
|
||||
)
|
||||
|
||||
|
||||
def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
"""Return if the bluetooth discovery matches an Avea bulb."""
|
||||
return AVEA_SERVICE_UUID in discovery_info.service_uuids
|
||||
|
||||
|
||||
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Avea."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = discovery_info
|
||||
self.context["title_placeholders"] = {
|
||||
"name": discovery_info.name or discovery_info.address
|
||||
}
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the discovered device before creating the entry."""
|
||||
assert self._discovery_info is not None
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
title = await self.hass.async_add_executor_job(
|
||||
_validate_device, self._discovery_info
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while validating Avea device")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_ADDRESS: self._discovery_info.address},
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": self._discovery_info.name or self._discovery_info.address
|
||||
}
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick a discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
discovery_info = self._discovered_devices[address]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
title = await self.hass.async_add_executor_job(
|
||||
_validate_device, discovery_info
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while validating Avea device")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_ADDRESS: address},
|
||||
)
|
||||
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not _is_avea_discovery(discovery)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
if self._discovery_info:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ADDRESS, default=self._discovery_info.address
|
||||
): vol.In(
|
||||
{
|
||||
self._discovery_info.address: (
|
||||
f"{self._discovery_info.name or self._discovery_info.address}"
|
||||
f" ({self._discovery_info.address})"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import from YAML."""
|
||||
address = import_data[CONF_ADDRESS]
|
||||
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_data.get(CONF_NAME, address),
|
||||
data={CONF_ADDRESS: address},
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate an Avea device cannot be connected to."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Constants for the Avea integration."""
|
||||
|
||||
DOMAIN = "avea"
|
||||
INTEGRATION_TITLE = "Elgato Avea"
|
||||
MANUFACTURER = "Elgato"
|
||||
MODEL = "Avea"
|
||||
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
|
||||
UNKNOWN_NAME = "Unknown"
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Support for the Elgato Avea lights."""
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import avea
|
||||
from bleak.exc import BleakError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -10,29 +13,153 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import AveaConfigEntry
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||
|
||||
def setup_platform(
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
BREAKS_IN_HA_VERSION = "2026.12.0"
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str | None:
|
||||
"""Return a valid Avea name."""
|
||||
if not name or name == UNKNOWN_NAME:
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
|
||||
"""Create the deprecated YAML issue for Avea."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_yaml_import_failed_issue(hass: HomeAssistant) -> None:
|
||||
"""Create a repair issue when the Avea YAML import cannot find bulbs."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_issue_no_bulbs",
|
||||
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_issue_no_bulbs",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AveaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
|
||||
|
||||
|
||||
def _discover_bulbs_for_import() -> list[dict[str, str]]:
|
||||
"""Discover and validate Avea bulbs for YAML import."""
|
||||
discovered_bulbs: list[dict[str, str]] = []
|
||||
|
||||
for bulb in avea.discover_avea_bulbs():
|
||||
address = bulb.addr
|
||||
try:
|
||||
name = bulb.get_name()
|
||||
brightness = bulb.get_brightness()
|
||||
except UPDATE_EXCEPTIONS as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea bulb %s during YAML import due to read failure: %s",
|
||||
address,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
with suppress(*UPDATE_EXCEPTIONS):
|
||||
bulb.close()
|
||||
|
||||
if brightness is None:
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
|
||||
address,
|
||||
)
|
||||
continue
|
||||
|
||||
discovered_bulbs.append(
|
||||
{
|
||||
CONF_ADDRESS: address,
|
||||
CONF_NAME: _normalize_name(name)
|
||||
or _normalize_name(bulb.name)
|
||||
or address,
|
||||
}
|
||||
)
|
||||
|
||||
return discovered_bulbs
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Avea platform."""
|
||||
"""Import the Avea YAML platform into config entries."""
|
||||
try:
|
||||
nearby_bulbs = avea.discover_avea_bulbs()
|
||||
for bulb in nearby_bulbs:
|
||||
bulb.get_name()
|
||||
bulb.get_brightness()
|
||||
except OSError as err:
|
||||
raise PlatformNotReady from err
|
||||
bulbs = await hass.async_add_executor_job(_discover_bulbs_for_import)
|
||||
except UPDATE_EXCEPTIONS as err:
|
||||
raise PlatformNotReady("Could not discover Avea bulbs for YAML import") from err
|
||||
|
||||
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
|
||||
if not bulbs:
|
||||
_create_yaml_import_failed_issue(hass)
|
||||
|
||||
for bulb in bulbs:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=bulb,
|
||||
)
|
||||
|
||||
if (
|
||||
result.get("type") is FlowResultType.ABORT
|
||||
and result.get("reason") != "already_configured"
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea YAML import for bulb %s: %s",
|
||||
bulb[CONF_ADDRESS],
|
||||
result.get("reason"),
|
||||
)
|
||||
continue
|
||||
|
||||
_create_deprecated_yaml_issue(hass)
|
||||
|
||||
|
||||
class AveaLight(LightEntity):
|
||||
@@ -41,7 +168,7 @@ class AveaLight(LightEntity):
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light):
|
||||
def __init__(self, light: avea.Bulb) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = light.name
|
||||
@@ -64,10 +191,7 @@ class AveaLight(LightEntity):
|
||||
self._light.set_brightness(0)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light.
|
||||
|
||||
This is the only method that should fetch new data for Home Assistant.
|
||||
"""
|
||||
"""Fetch new state data for this light."""
|
||||
if (brightness := self._light.get_brightness()) is not None:
|
||||
self._attr_is_on = brightness != 0
|
||||
self._attr_brightness = round(255 * (brightness / 4095))
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"domain": "avea",
|
||||
"name": "Elgato Avea",
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "Avea*",
|
||||
"service_uuid": "f815e810-456c-6761-746f-4d756e696368"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@pattyland"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/avea",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["avea"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["avea==1.6.1"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
|
||||
},
|
||||
"deprecated_yaml_import_issue_no_bulbs": {
|
||||
"description": "Configuring {integration_title} using YAML is deprecated and will be removed in a future release. While importing your YAML configuration, Home Assistant could not discover any Avea bulbs. Make sure the bulbs are powered on, nearby, and reachable over Bluetooth, then restart Home Assistant. If you no longer use the YAML configuration, remove the `{domain}` entry from your `configuration.yaml` file.",
|
||||
"title": "Avea YAML configuration import failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Config flow for the Bayesian integration."""
|
||||
# pylint: disable=hass-config-flow-name-field # Name field is no longer allowed in config flow schemas
|
||||
|
||||
from collections.abc import Mapping
|
||||
from enum import StrEnum
|
||||
|
||||
@@ -313,6 +313,8 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
data_schema = {vol.Required(CONF_NAME, default=device.name): str}
|
||||
return self.async_show_form(
|
||||
step_id="finish", data_schema=vol.Schema(data_schema), errors=errors
|
||||
|
||||
@@ -64,5 +64,7 @@ class BroadlinkEntity(Entity):
|
||||
manufacturer=device.api.manufacturer,
|
||||
model=device.api.model,
|
||||
name=device.name,
|
||||
sw_version=device.fw_version,
|
||||
sw_version=str(device.fw_version)
|
||||
if device.fw_version is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
@@ -38,7 +38,14 @@ from homeassistant.helpers.device_registry import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -118,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
|
||||
|
||||
# Read available heating circuits from config entry data
|
||||
# (populated by config flow or migration)
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS]
|
||||
circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] or list(
|
||||
DEFAULT_HEATING_CIRCUITS
|
||||
)
|
||||
|
||||
# Fetch required device metadata in parallel for faster startup
|
||||
device, info = await asyncio.gather(
|
||||
@@ -229,7 +238,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
# heating circuits from the device; fall back to [1] (pre-multi-circuit
|
||||
# default) if the device is unreachable or the endpoint is unsupported.
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
circuits: list[int] = [1]
|
||||
circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
config = BSBLANConfig(
|
||||
host=entry.data[CONF_HOST],
|
||||
passkey=entry.data[CONF_PASSKEY],
|
||||
@@ -245,11 +254,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
except (BSBLANError, TimeoutError) as err:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration failed for %s (%s); "
|
||||
"defaulting to single circuit [1]. Use Reconfigure to "
|
||||
"defaulting to a single circuit. Use Reconfigure to "
|
||||
"rediscover additional circuits later",
|
||||
entry.data[CONF_HOST],
|
||||
err,
|
||||
)
|
||||
if not circuits:
|
||||
LOGGER.warning(
|
||||
"Circuit discovery during migration returned no heating circuits "
|
||||
"for %s; defaulting to a single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
@@ -263,4 +279,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) ->
|
||||
circuits,
|
||||
)
|
||||
|
||||
# 1.2 -> 1.3: Repair entries that stored an empty circuit list during
|
||||
# discovery. Every BSB-LAN setup has at least one heating circuit.
|
||||
if entry.version == 1 and entry.minor_version < 3:
|
||||
if not entry.data[CONF_HEATING_CIRCUITS]:
|
||||
LOGGER.warning(
|
||||
"Stored heating circuits for %s are empty; defaulting to a "
|
||||
"single circuit",
|
||||
entry.data[CONF_HOST],
|
||||
)
|
||||
data = {
|
||||
**entry.data,
|
||||
CONF_HEATING_CIRCUITS: list(DEFAULT_HEATING_CIRCUITS),
|
||||
}
|
||||
else:
|
||||
data = {**entry.data}
|
||||
|
||||
hass.config_entries.async_update_entry(entry, data=data, minor_version=3)
|
||||
|
||||
return True
|
||||
|
||||
@@ -13,21 +13,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_HEATING_CIRCUITS,
|
||||
CONF_PASSKEY,
|
||||
DEFAULT_HEATING_CIRCUITS,
|
||||
DEFAULT_PORT,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
|
||||
class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a BSBLAN config flow."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize BSBLan flow."""
|
||||
self.host: str = ""
|
||||
self.port: int = DEFAULT_PORT
|
||||
self.mac: str | None = None
|
||||
self.circuits: list[int] = [1]
|
||||
self.circuits: list[int] = list(DEFAULT_HEATING_CIRCUITS)
|
||||
self.passkey: str | None = None
|
||||
self.username: str | None = None
|
||||
self.password: str | None = None
|
||||
@@ -384,6 +391,13 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
await bsblan.initialize()
|
||||
self.circuits = await bsblan.get_available_circuits()
|
||||
if not self.circuits:
|
||||
LOGGER.debug(
|
||||
"Circuit discovery returned no heating circuits for %s, "
|
||||
"defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
except (
|
||||
BSBLANError,
|
||||
TimeoutError,
|
||||
@@ -392,4 +406,4 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"Circuit discovery not available for %s, defaulting to single circuit",
|
||||
self.host,
|
||||
)
|
||||
self.circuits = [1]
|
||||
self.circuits = list(DEFAULT_HEATING_CIRCUITS)
|
||||
|
||||
@@ -22,4 +22,5 @@ ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature"
|
||||
CONF_PASSKEY: Final = "passkey"
|
||||
CONF_HEATING_CIRCUITS: Final = "heating_circuits"
|
||||
|
||||
DEFAULT_HEATING_CIRCUITS: Final = (1,)
|
||||
DEFAULT_PORT: Final = 80
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -21,13 +20,13 @@ class EncryptionRemovedRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the initial step of the repair flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle confirmation, remove the bindkey, and reload the entry."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self._entry_id)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""The CentriConnect/MyPropane API integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: CentriConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Set up CentriConnect/MyPropane API from a config entry."""
|
||||
coordinator = CentriConnectCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: CentriConnectConfigEntry
|
||||
) -> bool:
|
||||
"""Unload CentriConnect/MyPropane API integration platforms and coordinator."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Config flow for the CentriConnect/MyPropane API integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiocentriconnect import CentriConnect
|
||||
from aiocentriconnect.exceptions import (
|
||||
CentriConnectConnectionError,
|
||||
CentriConnectDecodeError,
|
||||
CentriConnectEmptyResponseError,
|
||||
CentriConnectNotFoundError,
|
||||
CentriConnectTooManyRequestsError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CENTRICONNECT_DEVICE_ID, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_DEVICE_ID): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
# Validate the user-supplied data can be used to set up a connection.
|
||||
hub = CentriConnect(
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_DEVICE_ID],
|
||||
data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
tank_data = await hub.async_get_tank_data()
|
||||
|
||||
# Return info to store in the config entry.
|
||||
return {
|
||||
"title": tank_data.device_name,
|
||||
CENTRICONNECT_DEVICE_ID: tank_data.device_id,
|
||||
}
|
||||
|
||||
|
||||
class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for CentriConnect/MyPropane API."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
info = await validate_input(self.hass, user_input)
|
||||
except CentriConnectConnectionError, CentriConnectTooManyRequestsError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except CentriConnectNotFoundError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except CentriConnectEmptyResponseError, CentriConnectDecodeError:
|
||||
errors["base"] = "unknown"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=user_input, reload_on_update=True
|
||||
)
|
||||
return self.async_create_entry(title=info["title"], data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Constants for the CentriConnect/MyPropane API integration."""
|
||||
|
||||
DOMAIN = "centriconnect"
|
||||
|
||||
CENTRICONNECT_DEVICE_ID = "device_id"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Coordinator for CentriConnect/MyPropane API integration.
|
||||
|
||||
Responsible for polling the device API endpoint and normalizing data for entities.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiocentriconnect import CentriConnect, Tank
|
||||
from aiocentriconnect.exceptions import CentriConnectConnectionError, CentriConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
COORDINATOR_NAME = f"{DOMAIN} Coordinator"
|
||||
# Maximum update frequency is every 6 hours. The API will return 429 Too Many Requests if polled frequently.
|
||||
# The device updates its data every 8-12 hours, so there's no need to poll more frequently.
|
||||
UPDATE_INTERVAL = timedelta(hours=6)
|
||||
|
||||
type CentriConnectConfigEntry = ConfigEntry[CentriConnectCoordinator]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CentriConnectDeviceInfo:
|
||||
"""Data about the CentriConnect device."""
|
||||
|
||||
device_id: str
|
||||
device_name: str
|
||||
hardware_version: str
|
||||
lte_version: str
|
||||
tank_size: int
|
||||
tank_size_unit: str
|
||||
|
||||
|
||||
class CentriConnectCoordinator(DataUpdateCoordinator[Tank]):
|
||||
"""Data update coordinator for CentriConnect/MyPropane devices."""
|
||||
|
||||
config_entry: CentriConnectConfigEntry
|
||||
device_info: CentriConnectDeviceInfo
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: CentriConnectConfigEntry) -> None:
|
||||
"""Initialize the CentriConnect data update coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=_LOGGER,
|
||||
name=COORDINATOR_NAME,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
self.api_client = CentriConnect(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_DEVICE_ID],
|
||||
entry.data[CONF_PASSWORD],
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
try:
|
||||
tank_data = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed("Could not fetch device info") from err
|
||||
self.device_info = CentriConnectDeviceInfo(
|
||||
device_id=tank_data.device_id,
|
||||
device_name=tank_data.device_name,
|
||||
hardware_version=tank_data.hardware_version,
|
||||
lte_version=tank_data.lte_version,
|
||||
tank_size=tank_data.tank_size,
|
||||
tank_size_unit=tank_data.tank_size_unit,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> Tank:
|
||||
"""Fetch device state."""
|
||||
try:
|
||||
state = await self.api_client.async_get_tank_data()
|
||||
except CentriConnectConnectionError as err:
|
||||
raise UpdateFailed(f"Error communicating with device: {err}") from err
|
||||
except CentriConnectError as err:
|
||||
raise UpdateFailed(f"Unexpected response: {err}") from err
|
||||
return state
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Defines a base CentriConnect entity."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CentriConnectCoordinator
|
||||
|
||||
|
||||
class CentriConnectBaseEntity(CoordinatorEntity[CentriConnectCoordinator]):
|
||||
"""Defines a base CentriConnect entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CentriConnectCoordinator,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the CentriConnect entity."""
|
||||
super().__init__(coordinator)
|
||||
if TYPE_CHECKING:
|
||||
assert coordinator.config_entry.unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
|
||||
name=coordinator.device_info.device_name,
|
||||
serial_number=coordinator.device_info.device_id,
|
||||
hw_version=coordinator.device_info.hardware_version,
|
||||
sw_version=coordinator.device_info.lte_version,
|
||||
manufacturer="CentriConnect",
|
||||
)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
|
||||
self.entity_description = description
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alert_status": {
|
||||
"default": "mdi:alert-circle-outline",
|
||||
"state": {
|
||||
"critical_level": "mdi:alert-circle",
|
||||
"low_level": "mdi:alert-circle-outline",
|
||||
"no_alert": "mdi:check-circle-outline"
|
||||
}
|
||||
},
|
||||
"altitude": {
|
||||
"default": "mdi:altimeter"
|
||||
},
|
||||
"battery_voltage": {
|
||||
"default": "mdi:car-battery"
|
||||
},
|
||||
"device_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
},
|
||||
"last_post_time": {
|
||||
"default": "mdi:clock-end"
|
||||
},
|
||||
"latitude": {
|
||||
"default": "mdi:latitude"
|
||||
},
|
||||
"longitude": {
|
||||
"default": "mdi:longitude"
|
||||
},
|
||||
"lte_signal_level": {
|
||||
"default": "mdi:signal",
|
||||
"range": {
|
||||
"0": "mdi:signal-cellular-outline",
|
||||
"25": "mdi:signal-cellular-1",
|
||||
"50": "mdi:signal-cellular-2",
|
||||
"75": "mdi:signal-cellular-3"
|
||||
}
|
||||
},
|
||||
"lte_signal_strength": {
|
||||
"default": "mdi:signal-variant"
|
||||
},
|
||||
"next_post_time": {
|
||||
"default": "mdi:clock-start"
|
||||
},
|
||||
"solar_level": {
|
||||
"default": "mdi:sun-wireless"
|
||||
},
|
||||
"solar_voltage": {
|
||||
"default": "mdi:solar-power"
|
||||
},
|
||||
"tank_level": {
|
||||
"default": "mdi:gauge",
|
||||
"range": {
|
||||
"0": "mdi:gauge-empty",
|
||||
"25": "mdi:gauge-low",
|
||||
"50": "mdi:gauge",
|
||||
"75": "mdi:gauge-full"
|
||||
}
|
||||
},
|
||||
"tank_remaining_volume": {
|
||||
"default": "mdi:storage-tank-outline"
|
||||
},
|
||||
"tank_size": {
|
||||
"default": "mdi:storage-tank"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "centriconnect",
|
||||
"name": "CentriConnect/MyPropane",
|
||||
"codeowners": ["@gresrun"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/centriconnect",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocentriconnect==0.2.3"]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration does not provide an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a cloud polling integration with no local discovery mechanism.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: This integration is not a hub and only represents a single device.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No user-actionable repair scenarios identified for this integration.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Devices removed from account stop appearing in API responses and become unavailable.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Sensor platform for CentriConnect/MyPropane API integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfLength,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator
|
||||
from .entity import CentriConnectBaseEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates.
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
_ALERT_STATUS_VALUES = {
|
||||
"No Alert": "no_alert",
|
||||
"Low Level": "low_level",
|
||||
"Critical Level": "critical_level",
|
||||
}
|
||||
|
||||
|
||||
class CentriConnectSensorType(StrEnum):
|
||||
"""Enumerates CentriConnect sensor types exposed by the device."""
|
||||
|
||||
ALERT_STATUS = "alert_status"
|
||||
ALTITUDE = "altitude"
|
||||
BATTERY_LEVEL = "battery_level"
|
||||
BATTERY_VOLTAGE = "battery_voltage"
|
||||
DEVICE_TEMPERATURE = "device_temperature"
|
||||
LAST_POST_TIME = "last_post_time"
|
||||
LATITUDE = "latitude"
|
||||
LONGITUDE = "longitude"
|
||||
LTE_SIGNAL_LEVEL = "lte_signal_level"
|
||||
LTE_SIGNAL_STRENGTH = "lte_signal_strength"
|
||||
NEXT_POST_TIME = "next_post_time"
|
||||
SOLAR_LEVEL = "solar_level"
|
||||
SOLAR_VOLTAGE = "solar_voltage"
|
||||
TANK_LEVEL = "tank_level"
|
||||
TANK_REMAINING_VOLUME = "tank_remaining_volume"
|
||||
TANK_SIZE = "tank_size"
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CentriConnectSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description of a CentriConnect sensor entity."""
|
||||
|
||||
key: CentriConnectSensorType
|
||||
value_fn: Callable[[CentriConnectCoordinator], StateType | datetime | None]
|
||||
|
||||
|
||||
ENTITIES: tuple[CentriConnectSensorEntityDescription, ...] = (
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.ALERT_STATUS,
|
||||
translation_key=CentriConnectSensorType.ALERT_STATUS,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=list(_ALERT_STATUS_VALUES.values()),
|
||||
value_fn=lambda coord: _ALERT_STATUS_VALUES.get(coord.data.alert_status),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.ALTITUDE,
|
||||
translation_key=CentriConnectSensorType.ALTITUDE,
|
||||
native_unit_of_measurement=UnitOfLength.METERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: coord.data.altitude,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.BATTERY_LEVEL,
|
||||
translation_key=CentriConnectSensorType.BATTERY_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coord: coord.data.battery_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.BATTERY_VOLTAGE,
|
||||
translation_key=CentriConnectSensorType.BATTERY_VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.battery_voltage,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.DEVICE_TEMPERATURE,
|
||||
translation_key=CentriConnectSensorType.DEVICE_TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda coord: coord.data.device_temperature,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
|
||||
translation_key=CentriConnectSensorType.LTE_SIGNAL_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.lte_signal_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
|
||||
translation_key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda coord: coord.data.lte_signal_strength,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.SOLAR_LEVEL,
|
||||
translation_key=CentriConnectSensorType.SOLAR_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.solar_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.SOLAR_VOLTAGE,
|
||||
translation_key=CentriConnectSensorType.SOLAR_VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
suggested_display_precision=0,
|
||||
value_fn=lambda coord: coord.data.solar_voltage,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_LEVEL,
|
||||
translation_key=CentriConnectSensorType.TANK_LEVEL,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda coord: coord.data.tank_level,
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.data.tank_remaining_volume
|
||||
if coord.device_info.tank_size_unit == "Gallons"
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.data.tank_remaining_volume
|
||||
if coord.device_info.tank_size_unit == "Liters"
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_SIZE,
|
||||
translation_key=CentriConnectSensorType.TANK_SIZE,
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.device_info.tank_size
|
||||
if (coord.device_info.tank_size_unit == "Gallons")
|
||||
else None
|
||||
),
|
||||
),
|
||||
CentriConnectSensorEntityDescription(
|
||||
key=CentriConnectSensorType.TANK_SIZE,
|
||||
translation_key=CentriConnectSensorType.TANK_SIZE,
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.VOLUME_STORAGE,
|
||||
suggested_display_precision=2,
|
||||
value_fn=lambda coord: (
|
||||
coord.device_info.tank_size
|
||||
if (coord.device_info.tank_size_unit == "Liters")
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CentriConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up CentriConnect sensor entities from a config entry."""
|
||||
async_add_entities(
|
||||
CentriConnectSensor(entry.runtime_data, description)
|
||||
for description in ENTITIES
|
||||
if description.value_fn(entry.runtime_data) is not None
|
||||
)
|
||||
|
||||
|
||||
class CentriConnectSensor(CentriConnectBaseEntity, SensorEntity):
|
||||
"""Representation of a CentriConnect sensor entity."""
|
||||
|
||||
entity_description: CentriConnectSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.coordinator)
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"device_id": "Device ID",
|
||||
"password": "Device Authentication Code",
|
||||
"username": "User ID"
|
||||
},
|
||||
"data_description": {
|
||||
"device_id": "Your CentriConnect/MyPropane device ID",
|
||||
"password": "Your CentriConnect/MyPropane device authentication code",
|
||||
"username": "Your CentriConnect/MyPropane user ID"
|
||||
},
|
||||
"description": "Enter your CentriConnect/MyPropane device credentials."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"alert_status": {
|
||||
"name": "Alert status",
|
||||
"state": {
|
||||
"critical_level": "Critical level",
|
||||
"low_level": "Low level",
|
||||
"no_alert": "No alert"
|
||||
}
|
||||
},
|
||||
"altitude": {
|
||||
"name": "Altitude"
|
||||
},
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
},
|
||||
"device_temperature": {
|
||||
"name": "Device temperature"
|
||||
},
|
||||
"lte_signal_level": {
|
||||
"name": "LTE signal level"
|
||||
},
|
||||
"lte_signal_strength": {
|
||||
"name": "LTE signal strength"
|
||||
},
|
||||
"solar_level": {
|
||||
"name": "Solar level"
|
||||
},
|
||||
"solar_voltage": {
|
||||
"name": "Solar voltage"
|
||||
},
|
||||
"tank_level": {
|
||||
"name": "Tank level"
|
||||
},
|
||||
"tank_remaining_volume": {
|
||||
"name": "Tank remaining volume"
|
||||
},
|
||||
"tank_size": {
|
||||
"name": "Tank size"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import voluptuous as vol
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
repairs_flow_manager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DATA_CLOUD, DOMAIN
|
||||
@@ -50,14 +50,14 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
wait_task: asyncio.Task | None = None
|
||||
_data: SubscriptionInfo | None = None
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm_change_plan()
|
||||
|
||||
async def async_step_confirm_change_plan(
|
||||
self,
|
||||
user_input: dict[str, str] | None = None,
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
return await self.async_step_change_plan()
|
||||
@@ -66,7 +66,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
step_id="confirm_change_plan", data_schema=vol.Schema({})
|
||||
)
|
||||
|
||||
async def async_step_change_plan(self, _: None = None) -> FlowResult:
|
||||
async def async_step_change_plan(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Wait for the user to authorize the app installation."""
|
||||
|
||||
cloud = self.hass.data[DATA_CLOUD]
|
||||
@@ -107,11 +107,11 @@ class LegacySubscriptionRepairFlow(RepairsFlow):
|
||||
|
||||
return self.async_external_step_done(next_step_id="complete")
|
||||
|
||||
async def async_step_complete(self, _: None = None) -> FlowResult:
|
||||
async def async_step_complete(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_timeout(self, _: None = None) -> FlowResult:
|
||||
async def async_step_timeout(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the final step of a fix flow."""
|
||||
return self.async_abort(reason="operation_took_too_long")
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from .const import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.COVER, Platform.LIGHT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Platform for Control4 Covers (blinds and shades)."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyControl4.blind import C4Blind
|
||||
from pyControl4.error_handling import C4Exception
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from . import Control4ConfigEntry, get_items_of_category
|
||||
from .const import CONTROL4_ENTITY_TYPE
|
||||
from .director_utils import update_variables_for_config_entry
|
||||
from .entity import Control4Entity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONTROL4_CATEGORY = "blinds_shades"
|
||||
|
||||
CONTROL4_LEVEL = "Level"
|
||||
CONTROL4_FULLY_CLOSED = "Fully Closed"
|
||||
CONTROL4_FULLY_OPEN = "Fully Open"
|
||||
CONTROL4_OPENING = "Opening"
|
||||
CONTROL4_CLOSING = "Closing"
|
||||
|
||||
VARIABLES_OF_INTEREST = {
|
||||
CONTROL4_LEVEL,
|
||||
CONTROL4_FULLY_CLOSED,
|
||||
CONTROL4_FULLY_OPEN,
|
||||
CONTROL4_OPENING,
|
||||
CONTROL4_CLOSING,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: Control4ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Control4 covers from a config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
|
||||
async def async_update_data() -> dict[int, dict[str, Any]]:
|
||||
"""Fetch data from Control4 director for blinds."""
|
||||
try:
|
||||
return await update_variables_for_config_entry(
|
||||
hass, entry, VARIABLES_OF_INTEREST
|
||||
)
|
||||
except C4Exception as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
|
||||
coordinator = DataUpdateCoordinator[dict[int, dict[str, Any]]](
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="cover",
|
||||
update_method=async_update_data,
|
||||
update_interval=timedelta(seconds=runtime_data.scan_interval),
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
await coordinator.async_refresh()
|
||||
|
||||
items_of_category = await get_items_of_category(hass, entry, CONTROL4_CATEGORY)
|
||||
entity_list = []
|
||||
for item in items_of_category:
|
||||
try:
|
||||
if item["type"] != CONTROL4_ENTITY_TYPE:
|
||||
continue
|
||||
item_name = item["name"]
|
||||
item_id = item["id"]
|
||||
item_parent_id = item["parentId"]
|
||||
item_manufacturer = None
|
||||
item_device_name = None
|
||||
item_model = None
|
||||
|
||||
for parent_item in items_of_category:
|
||||
if parent_item["id"] == item_parent_id:
|
||||
item_manufacturer = parent_item.get("manufacturer")
|
||||
item_device_name = parent_item.get("roomName")
|
||||
item_model = parent_item.get("model")
|
||||
except KeyError:
|
||||
_LOGGER.exception(
|
||||
"Unknown device properties received from Control4: %s",
|
||||
item,
|
||||
)
|
||||
continue
|
||||
|
||||
if item_id not in coordinator.data:
|
||||
_LOGGER.warning(
|
||||
"Couldn't get cover state data for %s (ID: %s), skipping setup",
|
||||
item_name,
|
||||
item_id,
|
||||
)
|
||||
continue
|
||||
|
||||
entity_list.append(
|
||||
Control4Cover(
|
||||
runtime_data,
|
||||
coordinator,
|
||||
item_name,
|
||||
item_id,
|
||||
item_device_name,
|
||||
item_manufacturer,
|
||||
item_model,
|
||||
item_parent_id,
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entity_list)
|
||||
|
||||
|
||||
class Control4Cover(Control4Entity, CoverEntity):
|
||||
"""Control4 cover entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "blind"
|
||||
_attr_device_class = CoverDeviceClass.SHADE
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.STOP
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self._cover_data is not None
|
||||
|
||||
def _create_api_object(self) -> C4Blind:
|
||||
"""Create a pyControl4 device object.
|
||||
|
||||
This exists so the director token used is always the latest one,
|
||||
without needing to re-init the entire entity.
|
||||
"""
|
||||
return C4Blind(self.runtime_data.director, self._idx)
|
||||
|
||||
@property
|
||||
def _cover_data(self) -> dict[str, Any] | None:
|
||||
"""Return the cover data from the coordinator."""
|
||||
return self.coordinator.data.get(self._idx)
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int | None:
|
||||
"""Return current position of cover (0 closed, 100 open)."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
level = data.get(CONTROL4_LEVEL)
|
||||
if level is None:
|
||||
return None
|
||||
return int(level)
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
"""Return if the cover is closed."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
if (fully_closed := data.get(CONTROL4_FULLY_CLOSED)) is not None:
|
||||
return bool(fully_closed)
|
||||
position = self.current_cover_position
|
||||
if position is None:
|
||||
return None
|
||||
return position == 0
|
||||
|
||||
@property
|
||||
def is_opening(self) -> bool | None:
|
||||
"""Return if the cover is opening."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
opening = data.get(CONTROL4_OPENING)
|
||||
if opening is None:
|
||||
return None
|
||||
return bool(opening)
|
||||
|
||||
@property
|
||||
def is_closing(self) -> bool | None:
|
||||
"""Return if the cover is closing."""
|
||||
data = self._cover_data
|
||||
if data is None:
|
||||
return None
|
||||
closing = data.get(CONTROL4_CLOSING)
|
||||
if closing is None:
|
||||
return None
|
||||
return bool(closing)
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.open()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.close()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the cover."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.stop()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
c4_blind = self._create_api_object()
|
||||
await c4_blind.setLevelTarget(kwargs[ATTR_POSITION])
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -30,6 +30,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_STOP_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LINE): str,
|
||||
@@ -103,6 +109,34 @@ class DataGrandLyonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
creds = {
|
||||
CONF_USERNAME: reconfigure_entry.data.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
}
|
||||
if error := await self._test_connection(creds):
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry, data_updates=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_RECONFIGURE_SCHEMA,
|
||||
user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
|
||||
"""Test connectivity by making a dummy API call.
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Diagnostics support for the Data Grand Lyon integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import DataGrandLyonConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: DataGrandLyonConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
|
||||
"coordinator_data": {
|
||||
subentry_id: [asdict(passage) for passage in passages]
|
||||
for subentry_id, passages in coordinator.data.items()
|
||||
},
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: This is a service integration; there are no discoverable devices.
|
||||
@@ -62,7 +62,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no known use cases for repair issues or flows, yet
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -20,6 +21,16 @@
|
||||
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::data_grand_lyon::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::data_grand_lyon::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The Denon RS232 integration."""
|
||||
"""The Denon RS-232 integration."""
|
||||
|
||||
from denon_rs232 import DenonReceiver, ReceiverState
|
||||
from denon_rs232.models import MODELS
|
||||
@@ -14,7 +14,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Set up Denon RS232 from a config entry."""
|
||||
"""Set up Denon RS-232 from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
model = MODELS[entry.data[CONF_MODEL]]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for the Denon RS232 integration."""
|
||||
"""Config flow for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -63,7 +63,7 @@ async def _async_attempt_connect(port: str, model_key: str) -> str | None:
|
||||
|
||||
|
||||
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon RS232."""
|
||||
"""Handle a config flow for Denon RS-232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
"""Constants for the Denon RS-232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS232",
|
||||
"name": "Denon RS-232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
"""Media player platform for the Denon RS-232 integration."""
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
@@ -77,7 +77,7 @@ async def async_setup_entry(
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon RS232 media player."""
|
||||
"""Set up the Denon RS-232 media player."""
|
||||
receiver = config_entry.runtime_data
|
||||
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
|
||||
|
||||
@@ -94,7 +94,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon receiver controlled over RS232."""
|
||||
"""Representation of a Denon receiver controlled over RS-232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@@ -53,6 +53,8 @@ def _schema_with_defaults(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=host): str,
|
||||
**AUTH_VOL_DICT,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Optional(CONF_NAME, default=name): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
@@ -17,13 +16,13 @@ class DoorBirdReloadConfirmRepairFlow(RepairsFlow):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
self.hass.config_entries.async_schedule_reload(self.entry_id)
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
"""The Duco integration."""
|
||||
|
||||
from duco import DucoClient, build_ssl_context
|
||||
import re
|
||||
|
||||
from duco_connectivity import DucoClient
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
|
||||
_REMOVED_SENSOR_RE = re.compile(r"_\d+_(box_)?temperature$")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Set up Duco from a config entry."""
|
||||
ssl_context = await hass.async_add_executor_job(build_ssl_context)
|
||||
# Remove entity registry entries for the temperature and box_temperature
|
||||
# sensors that were removed when migrating to python-duco-connectivity.
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
):
|
||||
if _REMOVED_SENSOR_RE.search(entity_entry.unique_id):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(hass),
|
||||
host=entry.data[CONF_HOST],
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
|
||||
coordinator = DucoCoordinator(hass, entry, client)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duco import DucoClient, build_ssl_context
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -158,11 +158,9 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
Returns a tuple of (box_name, mac_address).
|
||||
"""
|
||||
ssl_context = await self.hass.async_add_executor_job(build_ssl_context)
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
ssl_context=ssl_context,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
lan_info = await client.async_get_lan_info()
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from duco import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco.models import BoardInfo, Node
|
||||
from duco_connectivity import DucoClient
|
||||
from duco_connectivity.exceptions import DucoConnectionError, DucoError
|
||||
from duco_connectivity.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_HOST
|
||||
@@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics(
|
||||
api_info_obj = await coordinator.client.async_get_api_info()
|
||||
lan_info = await coordinator.client.async_get_lan_info()
|
||||
duco_diags = await coordinator.client.async_get_diagnostics()
|
||||
write_remaining = await coordinator.client.async_get_write_req_remaining()
|
||||
write_remaining = await coordinator.client.async_get_write_requests_remaining()
|
||||
except DucoConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from duco.models import Node
|
||||
from duco_connectivity.models import Node
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
from duco.exceptions import DucoError, DucoRateLimitError
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.exceptions import DucoError, DucoRateLimitError
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"loggers": ["duco_connectivity"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.5.0"],
|
||||
"requirements": ["python-duco-connectivity==0.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
|
||||
|
||||
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from duco.models import Node, NodeType, VentilationState
|
||||
from duco_connectivity.models import Node, NodeType, VentilationState
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -18,7 +18,6 @@ from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -54,20 +53,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
key="ventilation_state",
|
||||
translation_key="ventilation_state",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[s.lower() for s in VentilationState],
|
||||
options=[
|
||||
state.lower()
|
||||
for state in VentilationState
|
||||
if state != VentilationState.UNKNOWN
|
||||
],
|
||||
value_fn=lambda node: (
|
||||
node.ventilation.state.lower() if node.ventilation else None
|
||||
node.ventilation.state.lower()
|
||||
if node.ventilation and node.ventilation.state != VentilationState.UNKNOWN
|
||||
else None
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="target_flow_level",
|
||||
translation_key="target_flow_level",
|
||||
@@ -92,17 +89,6 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = (
|
||||
),
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="box_temperature",
|
||||
translation_key="box_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda node: node.sensor.temp if node.sensor else None,
|
||||
node_types=(NodeType.BOX,),
|
||||
),
|
||||
DucoSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
|
||||
@@ -47,9 +47,6 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"box_temperature": {
|
||||
"name": "Box temperature"
|
||||
},
|
||||
"iaq_co2": {
|
||||
"name": "CO2 air quality index"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from duco.exceptions import DucoConnectionError
|
||||
from duco_connectivity.exceptions import DucoConnectionError
|
||||
|
||||
from homeassistant.components import system_health
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -24,7 +24,9 @@ async def _async_get_write_requests_remaining(
|
||||
) -> int | dict[str, str]:
|
||||
"""Get the remaining write-request quota for system health."""
|
||||
try:
|
||||
return await config_entry.runtime_data.client.async_get_write_req_remaining()
|
||||
return (
|
||||
await config_entry.runtime_data.client.async_get_write_requests_remaining()
|
||||
)
|
||||
except DucoConnectionError:
|
||||
return {"type": "failed", "error": "unreachable"}
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]):
|
||||
)
|
||||
|
||||
except EasyEnergyConnectionError as err:
|
||||
raise UpdateFailed("Error communicating with easyEnergy API") from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_error",
|
||||
) from err
|
||||
|
||||
return EasyEnergyData(
|
||||
energy_today=energy_today,
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"average_price": {
|
||||
"default": "mdi:cash-multiple"
|
||||
},
|
||||
"current_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"highest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"hours_priced_equal_or_higher": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"hours_priced_equal_or_lower": {
|
||||
"default": "mdi:clock"
|
||||
},
|
||||
"lowest_price_time": {
|
||||
"default": "mdi:clock-outline"
|
||||
},
|
||||
"max_price": {
|
||||
"default": "mdi:cash-plus"
|
||||
},
|
||||
"min_price": {
|
||||
"default": "mdi:cash-minus"
|
||||
},
|
||||
"next_hour_price": {
|
||||
"default": "mdi:cash"
|
||||
},
|
||||
"percentage_of_max": {
|
||||
"default": "mdi:percent"
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ from .coordinator import (
|
||||
EasyEnergyDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class EasyEnergySensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_error": {
|
||||
"message": "Error communicating with the easyEnergy API."
|
||||
},
|
||||
"invalid_date": {
|
||||
"message": "Invalid date provided. Got {date}"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ECOWITT_BINARYSENSORS_MAPPING: Final = {
|
||||
EcoWittSensorTypes.LEAK: BinarySensorEntityDescription(
|
||||
key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE
|
||||
|
||||
@@ -38,6 +38,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ class EmulatedRokuFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=default_name): str,
|
||||
vol.Required(CONF_LISTEN_PORT, default=default_port): vol.Coerce(
|
||||
int
|
||||
|
||||
@@ -163,6 +163,9 @@ class BatterySourceType(TypedDict):
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
# statistic_id of a sensor (unit %) reporting the battery state of charge
|
||||
stat_soc: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -483,6 +486,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
vol.Optional("stat_soc"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_HOST): str,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=DOMAIN): str,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.21.0",
|
||||
"aioesphomeapi==44.24.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -4,8 +4,7 @@ from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .manager import async_replace_device
|
||||
@@ -43,7 +42,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
@@ -52,7 +51,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_migrate(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the migrate step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
@@ -66,7 +65,7 @@ class DeviceConflictRepair(ESPHomeRepair):
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the manual step of a fix flow."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -16,6 +16,7 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"sync_time": {
|
||||
"default": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-up"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-down"
|
||||
},
|
||||
"offset": {
|
||||
"default": "mdi:thermometer-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Comet Blue number integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eurotronic_cometblue_ha import AsyncCometBlue
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .climate import MAX_TEMP, MIN_TEMP
|
||||
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
from .entity import CometBlueBluetoothEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CometBlueNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes a Comet Blue number entity."""
|
||||
|
||||
cometblue_key: str
|
||||
set_fn: Callable[[AsyncCometBlue], Any]
|
||||
|
||||
|
||||
DESCRIPTIONS = [
|
||||
CometBlueNumberEntityDescription(
|
||||
key="offset",
|
||||
cometblue_key="tempOffset",
|
||||
translation_key="offset",
|
||||
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=-5.0,
|
||||
native_max_value=5.0,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="eco_setpoint",
|
||||
cometblue_key="targetTempLow",
|
||||
translation_key="eco_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="comfort_setpoint",
|
||||
cometblue_key="targetTempHigh",
|
||||
translation_key="comfort_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CometBlueConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the client entities."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[CometBlueNumberEntity] = [
|
||||
CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity):
|
||||
"""Representation of a number."""
|
||||
|
||||
entity_description: CometBlueNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CometBlueDataUpdateCoordinator,
|
||||
description: CometBlueNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize CometBlueNumberEntity."""
|
||||
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.address}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.coordinator.data.temperatures.get(
|
||||
self.entity_description.cometblue_key
|
||||
)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the device."""
|
||||
|
||||
await self.coordinator.send_command(
|
||||
self.entity_description.set_fn(self.coordinator.device),
|
||||
{
|
||||
"values": {
|
||||
# manual temperature always needs to be set, otherwise TRV will turn OFF
|
||||
"manualTemp": self.coordinator.data.temperatures["manualTemp"],
|
||||
self.entity_description.cometblue_key: value,
|
||||
}
|
||||
},
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -35,6 +35,17 @@
|
||||
"sync_time": {
|
||||
"name": "Sync time"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"name": "Comfort setpoint"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"name": "Eco setpoint"
|
||||
},
|
||||
"offset": {
|
||||
"name": "Setpoint offset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,8 @@ def get_model_selection_schema(
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(
|
||||
CONF_NAME,
|
||||
default=options.get(CONF_NAME) or vol.UNDEFINED,
|
||||
|
||||
@@ -34,7 +34,6 @@ from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_UDN,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import (
|
||||
CONF_FEATURE_DEVICE_TRACKING,
|
||||
@@ -225,19 +224,12 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the setup form to the user."""
|
||||
|
||||
advanced_data_schema: VolDictType = {}
|
||||
if self.show_advanced_options:
|
||||
advanced_data_schema = {
|
||||
vol.Optional(CONF_PORT): vol.Coerce(int),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): str,
|
||||
**advanced_data_schema,
|
||||
vol.Optional(CONF_PORT): vol.Coerce(int),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool,
|
||||
@@ -357,18 +349,14 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any], errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show the reconfigure form to the user."""
|
||||
advanced_data_schema: VolDictType = {}
|
||||
if self.show_advanced_options:
|
||||
advanced_data_schema = {
|
||||
vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(int),
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str,
|
||||
**advanced_data_schema,
|
||||
vol.Optional(CONF_PORT, default=user_input[CONF_PORT]): vol.Coerce(
|
||||
int
|
||||
),
|
||||
vol.Required(CONF_SSL, default=user_input[CONF_SSL]): bool,
|
||||
}
|
||||
),
|
||||
@@ -382,11 +370,21 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle reconfigure flow."""
|
||||
if user_input is None:
|
||||
reconfigure_entry_data = self._get_reconfigure_entry().data
|
||||
port = reconfigure_entry_data[CONF_PORT]
|
||||
ssl = reconfigure_entry_data.get(CONF_SSL, DEFAULT_SSL)
|
||||
|
||||
if (port == DEFAULT_HTTP_PORT and not ssl) or (
|
||||
port == DEFAULT_HTTPS_PORT and ssl
|
||||
):
|
||||
# don't show default ports in reconfigure flow, as they are determined by ssl value
|
||||
# this allows the user to toggle ssl without having to change the port
|
||||
port = vol.UNDEFINED
|
||||
|
||||
return self._show_setup_form_reconfigure(
|
||||
{
|
||||
CONF_HOST: reconfigure_entry_data[CONF_HOST],
|
||||
CONF_PORT: reconfigure_entry_data[CONF_PORT],
|
||||
CONF_SSL: reconfigure_entry_data.get(CONF_SSL, DEFAULT_SSL),
|
||||
CONF_PORT: port,
|
||||
CONF_SSL: ssl,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"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%]",
|
||||
@@ -25,10 +20,6 @@
|
||||
"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": {
|
||||
@@ -36,10 +27,6 @@
|
||||
"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": {
|
||||
@@ -47,7 +34,7 @@
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::fritzbox::common::data_description_host%]"
|
||||
"host": "The hostname or IP address of your FRITZ!Box router."
|
||||
},
|
||||
"description": "Update your configuration information for {name}."
|
||||
},
|
||||
@@ -58,9 +45,7 @@
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "[%key:component::fritzbox::common::data_description_host%]",
|
||||
"password": "[%key:component::fritzbox::common::data_description_password%]",
|
||||
"username": "[%key:component::fritzbox::common::data_description_username%]"
|
||||
"host": "The hostname or IP address of your FRITZ!Box router."
|
||||
},
|
||||
"description": "Enter your FRITZ!Box information."
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ class InvalidStreamException(HomeAssistantError):
|
||||
|
||||
def build_schema(
|
||||
is_options_flow: bool = False,
|
||||
show_advanced_options: bool = False,
|
||||
) -> vol.Schema:
|
||||
"""Create schema for camera config setup."""
|
||||
rtsp_options = [
|
||||
@@ -141,8 +140,7 @@ def build_schema(
|
||||
}
|
||||
if is_options_flow:
|
||||
advanced_section[vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE)] = bool
|
||||
if show_advanced_options:
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
advanced_section[vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS)] = bool
|
||||
|
||||
return vol.Schema(spec)
|
||||
|
||||
@@ -469,10 +467,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
build_schema(
|
||||
True,
|
||||
self.show_advanced_options,
|
||||
),
|
||||
build_schema(True),
|
||||
user_input or self.config_entry.options,
|
||||
),
|
||||
errors=errors,
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "Advanced settings are only needed for special cases. Leave them unchanged unless you know what you are doing.",
|
||||
"name": "Advanced settings"
|
||||
"description": "These options are only needed for special cases.",
|
||||
"name": "More options"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_PRESET_MODE,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
|
||||
PRESET_NONE,
|
||||
@@ -451,6 +452,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
return
|
||||
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
|
||||
self._target_temp = temperature
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
await self._async_control_heating(force=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ class GoalZeroFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
vol.Required(
|
||||
CONF_HOST, default=user_input.get(CONF_HOST) or ""
|
||||
): str,
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Optional(
|
||||
CONF_NAME, default=user_input.get(CONF_NAME) or DEFAULT_NAME
|
||||
): str,
|
||||
|
||||
@@ -69,6 +69,8 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
"""Return the schema for a location with default values from the hass config."""
|
||||
return vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=hass.config.location_name): str,
|
||||
vol.Required(
|
||||
CONF_LOCATION,
|
||||
|
||||
@@ -1201,6 +1201,17 @@ class TemperatureSettingTrait(_Trait):
|
||||
preset_to_google = {climate.PRESET_ECO: "eco"}
|
||||
google_to_preset = {value: key for key, value in preset_to_google.items()}
|
||||
|
||||
action_to_google = {
|
||||
climate.HVACAction.OFF: "off",
|
||||
climate.HVACAction.HEATING: "heat",
|
||||
climate.HVACAction.DEFROSTING: "heat",
|
||||
climate.HVACAction.PREHEATING: "heat",
|
||||
climate.HVACAction.COOLING: "cool",
|
||||
climate.HVACAction.DRYING: "dry",
|
||||
climate.HVACAction.FAN: "fan-only",
|
||||
climate.HVACAction.IDLE: "none",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
@@ -1284,6 +1295,11 @@ class TemperatureSettingTrait(_Trait):
|
||||
else:
|
||||
response["thermostatMode"] = self.hvac_to_google.get(operation, "none")
|
||||
|
||||
if (
|
||||
action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION))
|
||||
) is not None:
|
||||
response["activeThermostatMode"] = action
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
response["thermostatTemperatureAmbient"] = round(
|
||||
|
||||
@@ -321,6 +321,8 @@ async def google_generative_ai_config_option_schema(
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=default_name): str,
|
||||
}
|
||||
else:
|
||||
|
||||
@@ -172,7 +172,7 @@ def _format_tool(
|
||||
def _escape_decode(value: Any) -> Any:
|
||||
"""Recursively call codecs.escape_decode on all values."""
|
||||
if isinstance(value, str):
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8")
|
||||
if isinstance(value, list):
|
||||
return [_escape_decode(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
|
||||
@@ -67,6 +67,8 @@ RECONFIGURE_SCHEMA = vol.Schema(
|
||||
|
||||
CONFIG_SCHEMA = RECONFIGURE_SCHEMA.extend(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -70,6 +70,8 @@ def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
|
||||
"""Return the schema for a location with default values from the hass config."""
|
||||
return vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Required(CONF_NAME, default=hass.config.location_name): str,
|
||||
vol.Required(
|
||||
CONF_LOCATION,
|
||||
|
||||
@@ -38,7 +38,10 @@ from .util import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_SCHEMA = vol.Schema(
|
||||
{vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}, extra=vol.ALLOW_EXTRA
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
{vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioharmony", "slixmpp"],
|
||||
"requirements": ["aioharmony==0.5.3"],
|
||||
"requirements": ["aioharmony==1.0.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:myharmony-com:device:harmony:1",
|
||||
|
||||
@@ -162,6 +162,7 @@ ISSUE_KEY_ADDON_PWNED = "issue_addon_pwned"
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE = "issue_system_free_space"
|
||||
ISSUE_KEY_ADDON_DEPRECATED = "issue_addon_deprecated_addon"
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH = "issue_addon_deprecated_arch_addon"
|
||||
ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER = "legacy_homeassistant_folder"
|
||||
|
||||
ISSUE_MOUNT_MOUNT_FAILED = "issue_mount_mount_failed"
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ from aiohasupervisor import SupervisorError
|
||||
from aiohasupervisor.models import ContextType
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.components.repairs import (
|
||||
ConfirmRepairFlow,
|
||||
RepairsFlow,
|
||||
RepairsFlowResult,
|
||||
)
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from . import get_addons_list
|
||||
from .const import (
|
||||
@@ -22,6 +25,7 @@ from .const import (
|
||||
ISSUE_KEY_ADDON_DEPRECATED_ARCH,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER,
|
||||
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
|
||||
PLACEHOLDER_KEY_ADDON,
|
||||
PLACEHOLDER_KEY_ADDON_DOCUMENTATION,
|
||||
@@ -77,7 +81,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
return placeholders or None
|
||||
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
|
||||
def _async_form_for_suggestion(self, suggestion: Suggestion) -> RepairsFlowResult:
|
||||
"""Return form for suggestion."""
|
||||
return self.async_show_form(
|
||||
step_id=suggestion.key,
|
||||
@@ -86,7 +90,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
last_step=True,
|
||||
)
|
||||
|
||||
async def async_step_init(self, _: None = None) -> FlowResult:
|
||||
async def async_step_init(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
# Out of sync with supervisor, issue is resolved or not fixable. Remove it
|
||||
if not self.issue or not self.issue.suggestions:
|
||||
@@ -108,7 +112,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
# Always show a form for one suggestion to explain to user what's happening
|
||||
return self._async_form_for_suggestion(self.issue.suggestions[0])
|
||||
|
||||
async def async_step_fix_menu(self, _: None = None) -> FlowResult:
|
||||
async def async_step_fix_menu(self, _: None = None) -> RepairsFlowResult:
|
||||
"""Show the fix menu."""
|
||||
assert self.issue
|
||||
|
||||
@@ -120,7 +124,7 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
|
||||
async def _async_step_apply_suggestion(
|
||||
self, suggestion: Suggestion, confirmed: bool = False
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle applying a suggestion as a flow step. Optionally request confirmation."""
|
||||
if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED:
|
||||
return self._async_form_for_suggestion(suggestion)
|
||||
@@ -137,13 +141,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
|
||||
suggestion: Suggestion,
|
||||
) -> Callable[
|
||||
[SupervisorIssueRepairFlow, dict[str, str] | None],
|
||||
Coroutine[Any, Any, FlowResult],
|
||||
Coroutine[Any, Any, RepairsFlowResult],
|
||||
]:
|
||||
"""Generate a step handler for a suggestion."""
|
||||
|
||||
async def _async_step(
|
||||
self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle a flow step for a suggestion."""
|
||||
return await self._async_step_apply_suggestion(
|
||||
suggestion, confirmed=user_input is not None
|
||||
@@ -227,6 +231,8 @@ async def async_create_fix_flow(
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER:
|
||||
return ConfirmRepairFlow()
|
||||
supervisor_issues = get_issues_info(hass)
|
||||
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
|
||||
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
Folder,
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
@@ -27,6 +28,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
issue_registry as ir,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
@@ -46,6 +48,7 @@ from .const import (
|
||||
ATTR_PASSWORD,
|
||||
ATTR_SLUG,
|
||||
DOMAIN,
|
||||
ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER,
|
||||
MAIN_COORDINATOR,
|
||||
SupervisorEntityModel,
|
||||
)
|
||||
@@ -70,6 +73,43 @@ 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(
|
||||
hass: HomeAssistant, 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
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER,
|
||||
breaks_in_ha_version="2026.12.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=ISSUE_KEY_LEGACY_HOMEASSISTANT_FOLDER,
|
||||
)
|
||||
if folders:
|
||||
data[ATTR_FOLDERS] = folders
|
||||
else:
|
||||
data.pop(ATTR_FOLDERS)
|
||||
return data
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
@@ -113,7 +153,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -136,7 +179,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -343,9 +389,7 @@ def async_register_backup_restore_services(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(hass, service.data.copy())
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
@@ -392,8 +436,7 @@ def async_register_backup_restore_services(
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(hass, data)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
|
||||
@@ -203,6 +203,17 @@
|
||||
},
|
||||
"title": "Reboot required"
|
||||
},
|
||||
"legacy_homeassistant_folder": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "An automation or script called the `hassio.backup_partial` or `hassio.restore_partial` action with `\"homeassistant\"` listed in `folders`. This is a legacy alias for the `homeassistant: true` option and will stop being accepted in a future release.\n\nUpdate the affected automations and scripts to set `homeassistant: true` and remove `\"homeassistant\"` from the `folders` list. When this is done, select **Submit** to mark this issue as resolved.",
|
||||
"title": "Legacy \"homeassistant\" folder used in partial backup/restore"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Legacy \"homeassistant\" folder used in partial backup/restore"
|
||||
},
|
||||
"unhealthy": {
|
||||
"description": "System is currently unhealthy due to {reason}. For troubleshooting information, select Learn more.",
|
||||
"title": "Unhealthy system - {reason}"
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand
|
||||
from pycec.const import (
|
||||
CMD_STANDBY,
|
||||
KEY_BACKWARD,
|
||||
KEY_FORWARD,
|
||||
KEY_MUTE_TOGGLE,
|
||||
@@ -93,7 +94,7 @@ class CecPlayerEntity(CecEntity, MediaPlayerEntity):
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pycec.const import POWER_OFF, POWER_ON
|
||||
from pycec.commands import CecCommand
|
||||
from pycec.const import CMD_STANDBY, POWER_OFF, POWER_ON
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -50,7 +51,7 @@ class CecSwitchEntity(CecEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn device off."""
|
||||
self._device.turn_off()
|
||||
self._device.send_command(CecCommand(CMD_STANDBY, dst=self._logical_address))
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ def get_user_step_schema(data: Mapping[str, Any]) -> vol.Schema:
|
||||
travel_mode = TRAVEL_MODE_PUBLIC
|
||||
return vol.Schema(
|
||||
{
|
||||
# Name field is no longer allowed in config flow schemas
|
||||
# pylint: disable-next=hass-config-flow-name-field
|
||||
vol.Optional(
|
||||
CONF_NAME, default=data.get(CONF_NAME, DEFAULT_NAME)
|
||||
): cv.string,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user