mirror of
https://github.com/home-assistant/core.git
synced 2026-05-28 03:33:19 +02:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e425e002 | |||
| 6e03333da6 | |||
| 00b8e67639 | |||
| 03a7590d20 | |||
| 5ffcd04ccb | |||
| 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 | |||
| 65f073ca15 |
@@ -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.*
|
||||
|
||||
Generated
+3
@@ -196,6 +196,7 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/autoskope/ @mcisk
|
||||
/tests/components/autoskope/ @mcisk
|
||||
/homeassistant/components/avea/ @pattyland
|
||||
/tests/components/avea/ @pattyland
|
||||
/homeassistant/components/awair/ @ahayworth @ricohageman
|
||||
/tests/components/awair/ @ahayworth @ricohageman
|
||||
/homeassistant/components/aws_s3/ @tomasbedrich
|
||||
@@ -288,6 +289,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/cast/ @emontnemery
|
||||
/homeassistant/components/ccm15/ @ocalvo
|
||||
/tests/components/ccm15/ @ocalvo
|
||||
/homeassistant/components/centriconnect/ @gresrun
|
||||
/tests/components/centriconnect/ @gresrun
|
||||
/homeassistant/components/cert_expiry/ @jjlawren
|
||||
/tests/components/cert_expiry/ @jjlawren
|
||||
/homeassistant/components/chacon_dio/ @cnico
|
||||
|
||||
+12
-13
@@ -9,10 +9,21 @@ import threading
|
||||
|
||||
from .backup_restore import restore_backup
|
||||
from .const import REQUIRED_PYTHON_VER, RESTART_EXIT_CODE, __version__
|
||||
from .util.package import is_docker_env, is_virtual_env
|
||||
|
||||
FAULT_LOG_FILENAME = "home-assistant.log.fault"
|
||||
|
||||
|
||||
def validate_environment() -> None:
|
||||
"""Validate that Home Assistant is started from a container or a venv."""
|
||||
if not is_virtual_env() and not is_docker_env():
|
||||
print(
|
||||
"Home Assistant must be run in a Python virtual environment or a container.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def validate_os() -> None:
|
||||
"""Validate that Home Assistant is running in a supported operating system."""
|
||||
if not sys.platform.startswith(("darwin", "linux")):
|
||||
@@ -38,8 +49,6 @@ def ensure_config_path(config_dir: str) -> None:
|
||||
"""Validate the configuration directory."""
|
||||
from . import config as config_util # noqa: PLC0415
|
||||
|
||||
lib_dir = os.path.join(config_dir, "deps")
|
||||
|
||||
# Test if configuration directory exists
|
||||
if not os.path.isdir(config_dir):
|
||||
if config_dir != config_util.get_default_config_dir():
|
||||
@@ -63,17 +72,6 @@ def ensure_config_path(config_dir: str) -> None:
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Test if library directory exists
|
||||
if not os.path.isdir(lib_dir):
|
||||
try:
|
||||
os.mkdir(lib_dir)
|
||||
except OSError as ex:
|
||||
print(
|
||||
f"Fatal Error: Unable to create library directory {lib_dir}: {ex}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_arguments() -> argparse.Namespace:
|
||||
"""Get parsed passed in arguments."""
|
||||
@@ -166,6 +164,7 @@ def check_threads() -> None:
|
||||
def main() -> int:
|
||||
"""Start Home Assistant."""
|
||||
validate_python()
|
||||
validate_environment()
|
||||
|
||||
args = get_arguments()
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ from .setup import (
|
||||
from .util.async_ import create_eager_task
|
||||
from .util.hass_dict import HassKey
|
||||
from .util.logging import async_activate_log_queue_handler
|
||||
from .util.package import async_get_user_site, is_docker_env, is_virtual_env
|
||||
from .util.package import is_docker_env
|
||||
from .util.system_info import is_official_image
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
@@ -351,9 +351,6 @@ async def async_setup_hass(
|
||||
err,
|
||||
)
|
||||
else:
|
||||
if not is_virtual_env():
|
||||
await async_mount_local_lib_path(runtime_config.config_dir)
|
||||
|
||||
if hass.config.safe_mode:
|
||||
_LOGGER.info("Starting in safe mode")
|
||||
|
||||
@@ -702,17 +699,6 @@ class _RotatingFileHandlerWithoutShouldRollOver(RotatingFileHandler):
|
||||
return False
|
||||
|
||||
|
||||
async def async_mount_local_lib_path(config_dir: str) -> str:
|
||||
"""Add local library to Python Path.
|
||||
|
||||
This function is a coroutine.
|
||||
"""
|
||||
deps_dir = os.path.join(config_dir, "deps")
|
||||
if (lib_dir := await async_get_user_site(deps_dir)) not in sys.path:
|
||||
sys.path.insert(0, lib_dir)
|
||||
return deps_dir
|
||||
|
||||
|
||||
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
|
||||
"""Get domains of components to set up."""
|
||||
# The common config section [homeassistant] could be filtered here,
|
||||
|
||||
@@ -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.2"]
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: Reports are polled every 30 minutes so newly published hourly AirNow reports are picked up promptly.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: todo
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: done
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: The ozone sensor can still use the ozone device class.
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
stale-devices: todo
|
||||
repair-issues: todo
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -46,6 +46,9 @@
|
||||
"init": {
|
||||
"data": {
|
||||
"radius": "Station radius (miles)"
|
||||
},
|
||||
"data_description": {
|
||||
"radius": "The radius in miles around your location to search for reporting stations."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
"""The avea component."""
|
||||
"""The Avea integration."""
|
||||
|
||||
import avea
|
||||
|
||||
from homeassistant.components.bluetooth import async_ble_device_from_address
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
type AveaConfigEntry = ConfigEntry[avea.Bulb]
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Set up Avea from a config entry."""
|
||||
ble_device = async_ble_device_from_address(
|
||||
hass, entry.data[CONF_ADDRESS], connectable=True
|
||||
)
|
||||
if not ble_device:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not find Avea device with address {entry.data[CONF_ADDRESS]}"
|
||||
)
|
||||
|
||||
entry.runtime_data = avea.Bulb(ble_device)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AveaConfigEntry) -> bool:
|
||||
"""Unload an Avea config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Config flow for Avea."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import avea
|
||||
from bleak.exc import BleakError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
|
||||
from .const import AVEA_SERVICE_UUID, DOMAIN, UNKNOWN_NAME
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str | None:
|
||||
"""Return a valid Avea name."""
|
||||
if not name or name == UNKNOWN_NAME:
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _validate_device(discovery_info: BluetoothServiceInfoBleak) -> str:
|
||||
"""Validate the device is reachable and return a title for it."""
|
||||
bulb = avea.Bulb(discovery_info.device)
|
||||
|
||||
try:
|
||||
if not bulb.connect():
|
||||
raise CannotConnect
|
||||
|
||||
try:
|
||||
name = bulb.get_name()
|
||||
except BleakError, OSError, RuntimeError:
|
||||
_LOGGER.debug(
|
||||
"Failed to get name for Avea device %s",
|
||||
discovery_info.address,
|
||||
exc_info=True,
|
||||
)
|
||||
name = None
|
||||
brightness = bulb.get_brightness()
|
||||
except (BleakError, OSError, RuntimeError) as err:
|
||||
raise CannotConnect from err
|
||||
finally:
|
||||
with suppress(BleakError, OSError, RuntimeError):
|
||||
bulb.close()
|
||||
|
||||
if brightness is None:
|
||||
raise CannotConnect
|
||||
|
||||
return (
|
||||
_normalize_name(name)
|
||||
or _normalize_name(discovery_info.name)
|
||||
or discovery_info.address
|
||||
)
|
||||
|
||||
|
||||
def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
|
||||
"""Return if the bluetooth discovery matches an Avea bulb."""
|
||||
return AVEA_SERVICE_UUID in discovery_info.service_uuids
|
||||
|
||||
|
||||
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Avea."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._discovery_info = discovery_info
|
||||
self.context["title_placeholders"] = {
|
||||
"name": discovery_info.name or discovery_info.address
|
||||
}
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the discovered device before creating the entry."""
|
||||
assert self._discovery_info is not None
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
title = await self.hass.async_add_executor_job(
|
||||
_validate_device, self._discovery_info
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while validating Avea device")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_ADDRESS: self._discovery_info.address},
|
||||
)
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": self._discovery_info.name or self._discovery_info.address
|
||||
}
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders=self.context["title_placeholders"],
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick a discovered device."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
discovery_info = self._discovered_devices[address]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
title = await self.hass.async_add_executor_job(
|
||||
_validate_device, discovery_info
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while validating Avea device")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={CONF_ADDRESS: address},
|
||||
)
|
||||
|
||||
if discovery := self._discovery_info:
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
else:
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery in async_discovered_service_info(self.hass):
|
||||
if (
|
||||
discovery.address in current_addresses
|
||||
or discovery.address in self._discovered_devices
|
||||
or not _is_avea_discovery(discovery)
|
||||
):
|
||||
continue
|
||||
self._discovered_devices[discovery.address] = discovery
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
if self._discovery_info:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_ADDRESS, default=self._discovery_info.address
|
||||
): vol.In(
|
||||
{
|
||||
self._discovery_info.address: (
|
||||
f"{self._discovery_info.name or self._discovery_info.address}"
|
||||
f" ({self._discovery_info.address})"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
else:
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle import from YAML."""
|
||||
address = import_data[CONF_ADDRESS]
|
||||
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=import_data.get(CONF_NAME, address),
|
||||
data={CONF_ADDRESS: address},
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(Exception):
|
||||
"""Error to indicate an Avea device cannot be connected to."""
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Constants for the Avea integration."""
|
||||
|
||||
DOMAIN = "avea"
|
||||
INTEGRATION_TITLE = "Elgato Avea"
|
||||
MANUFACTURER = "Elgato"
|
||||
MODEL = "Avea"
|
||||
AVEA_SERVICE_UUID = "f815e810-456c-6761-746f-4d756e696368"
|
||||
UNKNOWN_NAME = "Unknown"
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Support for the Elgato Avea lights."""
|
||||
"""Light platform for Avea."""
|
||||
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import avea
|
||||
from bleak.exc import BleakError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -10,29 +13,153 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import AveaConfigEntry
|
||||
from .const import DOMAIN, INTEGRATION_TITLE, UNKNOWN_NAME
|
||||
|
||||
def setup_platform(
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
UPDATE_EXCEPTIONS = (BleakError, OSError, RuntimeError)
|
||||
BREAKS_IN_HA_VERSION = "2026.12.0"
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str | None:
|
||||
"""Return a valid Avea name."""
|
||||
if not name or name == UNKNOWN_NAME:
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _create_deprecated_yaml_issue(hass: HomeAssistant) -> None:
|
||||
"""Create the deprecated YAML issue for Avea."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_yaml_import_failed_issue(hass: HomeAssistant) -> None:
|
||||
"""Create a repair issue when the Avea YAML import cannot find bulbs."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_issue_no_bulbs",
|
||||
breaks_in_ha_version=BREAKS_IN_HA_VERSION,
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml_import_issue_no_bulbs",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": INTEGRATION_TITLE,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: AveaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Avea light platform."""
|
||||
async_add_entities([AveaLight(entry.runtime_data)], update_before_add=True)
|
||||
|
||||
|
||||
def _discover_bulbs_for_import() -> list[dict[str, str]]:
|
||||
"""Discover and validate Avea bulbs for YAML import."""
|
||||
discovered_bulbs: list[dict[str, str]] = []
|
||||
|
||||
for bulb in avea.discover_avea_bulbs():
|
||||
address = bulb.addr
|
||||
try:
|
||||
name = bulb.get_name()
|
||||
brightness = bulb.get_brightness()
|
||||
except UPDATE_EXCEPTIONS as err:
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea bulb %s during YAML import due to read failure: %s",
|
||||
address,
|
||||
err,
|
||||
)
|
||||
continue
|
||||
finally:
|
||||
with suppress(*UPDATE_EXCEPTIONS):
|
||||
bulb.close()
|
||||
|
||||
if brightness is None:
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea bulb %s during YAML import due to read failure: brightness is None",
|
||||
address,
|
||||
)
|
||||
continue
|
||||
|
||||
discovered_bulbs.append(
|
||||
{
|
||||
CONF_ADDRESS: address,
|
||||
CONF_NAME: _normalize_name(name)
|
||||
or _normalize_name(bulb.name)
|
||||
or address,
|
||||
}
|
||||
)
|
||||
|
||||
return discovered_bulbs
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Avea platform."""
|
||||
"""Import the Avea YAML platform into config entries."""
|
||||
try:
|
||||
nearby_bulbs = avea.discover_avea_bulbs()
|
||||
for bulb in nearby_bulbs:
|
||||
bulb.get_name()
|
||||
bulb.get_brightness()
|
||||
except OSError as err:
|
||||
raise PlatformNotReady from err
|
||||
bulbs = await hass.async_add_executor_job(_discover_bulbs_for_import)
|
||||
except UPDATE_EXCEPTIONS as err:
|
||||
raise PlatformNotReady("Could not discover Avea bulbs for YAML import") from err
|
||||
|
||||
add_entities(AveaLight(bulb) for bulb in nearby_bulbs)
|
||||
if not bulbs:
|
||||
_create_yaml_import_failed_issue(hass)
|
||||
|
||||
for bulb in bulbs:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=bulb,
|
||||
)
|
||||
|
||||
if (
|
||||
result.get("type") is FlowResultType.ABORT
|
||||
and result.get("reason") != "already_configured"
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Skipping Avea YAML import for bulb %s: %s",
|
||||
bulb[CONF_ADDRESS],
|
||||
result.get("reason"),
|
||||
)
|
||||
continue
|
||||
|
||||
_create_deprecated_yaml_issue(hass)
|
||||
|
||||
|
||||
class AveaLight(LightEntity):
|
||||
@@ -41,7 +168,7 @@ class AveaLight(LightEntity):
|
||||
_attr_color_mode = ColorMode.HS
|
||||
_attr_supported_color_modes = {ColorMode.HS}
|
||||
|
||||
def __init__(self, light):
|
||||
def __init__(self, light: avea.Bulb) -> None:
|
||||
"""Initialize an AveaLight."""
|
||||
self._light = light
|
||||
self._attr_name = light.name
|
||||
@@ -64,10 +191,7 @@ class AveaLight(LightEntity):
|
||||
self._light.set_brightness(0)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch new state data for this light.
|
||||
|
||||
This is the only method that should fetch new data for Home Assistant.
|
||||
"""
|
||||
"""Fetch new state data for this light."""
|
||||
if (brightness := self._light.get_brightness()) is not None:
|
||||
self._attr_is_on = brightness != 0
|
||||
self._attr_brightness = round(255 * (brightness / 4095))
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
{
|
||||
"domain": "avea",
|
||||
"name": "Elgato Avea",
|
||||
"bluetooth": [
|
||||
{
|
||||
"local_name": "Avea*",
|
||||
"service_uuid": "f815e810-456c-6761-746f-4d756e696368"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@pattyland"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/avea",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["avea"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["avea==1.6.1"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"description": "[%key:component::homeassistant::issues::deprecated_yaml::description%]",
|
||||
"title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]"
|
||||
},
|
||||
"deprecated_yaml_import_issue_no_bulbs": {
|
||||
"description": "Configuring {integration_title} using YAML is deprecated and will be removed in a future release. While importing your YAML configuration, Home Assistant could not discover any Avea bulbs. Make sure the bulbs are powered on, nearby, and reachable over Bluetooth, then restart Home Assistant. If you no longer use the YAML configuration, remove the `{domain}` entry from your `configuration.yaml` file.",
|
||||
"title": "Avea YAML configuration import failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==70"],
|
||||
"requirements": ["axis==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -64,5 +64,7 @@ class BroadlinkEntity(Entity):
|
||||
manufacturer=device.api.manufacturer,
|
||||
model=device.api.model,
|
||||
name=device.name,
|
||||
sw_version=device.fw_version,
|
||||
sw_version=str(device.fw_version)
|
||||
if device.fw_version is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -17,6 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
ECOWITT_BINARYSENSORS_MAPPING: Final = {
|
||||
EcoWittSensorTypes.LEAK: BinarySensorEntityDescription(
|
||||
key="LEAK", device_class=BinarySensorDeviceClass.MOISTURE
|
||||
|
||||
@@ -38,6 +38,8 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
|
||||
from . import EcowittConfigEntry
|
||||
from .entity import EcowittEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,9 @@ class BatterySourceType(TypedDict):
|
||||
# User's original power sensor configuration
|
||||
power_config: NotRequired[PowerConfig]
|
||||
|
||||
# statistic_id of a sensor (unit %) reporting the battery state of charge
|
||||
stat_soc: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
"""Dictionary holding the source of gas consumption."""
|
||||
@@ -483,6 +486,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
# If power_config is provided, it takes precedence and stat_rate is overwritten
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
|
||||
vol.Optional("stat_soc"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
"sync_time": {
|
||||
"default": "mdi:calendar-clock"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-up"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"default": "mdi:thermometer-chevron-down"
|
||||
},
|
||||
"offset": {
|
||||
"default": "mdi:thermometer-check"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Comet Blue number integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from eurotronic_cometblue_ha import AsyncCometBlue
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PRECISION_HALVES, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .climate import MAX_TEMP, MIN_TEMP
|
||||
from .coordinator import CometBlueConfigEntry, CometBlueDataUpdateCoordinator
|
||||
from .entity import CometBlueBluetoothEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class CometBlueNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes a Comet Blue number entity."""
|
||||
|
||||
cometblue_key: str
|
||||
set_fn: Callable[[AsyncCometBlue], Any]
|
||||
|
||||
|
||||
DESCRIPTIONS = [
|
||||
CometBlueNumberEntityDescription(
|
||||
key="offset",
|
||||
cometblue_key="tempOffset",
|
||||
translation_key="offset",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=-5.0,
|
||||
native_max_value=5.0,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="eco_setpoint",
|
||||
cometblue_key="targetTempLow",
|
||||
translation_key="eco_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
CometBlueNumberEntityDescription(
|
||||
key="comfort_setpoint",
|
||||
cometblue_key="targetTempHigh",
|
||||
translation_key="comfort_setpoint",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
set_fn=lambda x: x.set_temperature_async,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=PRECISION_HALVES,
|
||||
entity_registry_enabled_default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: CometBlueConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the client entities."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
entities: list[CometBlueNumberEntity] = [
|
||||
CometBlueNumberEntity(coordinator, description) for description in DESCRIPTIONS
|
||||
]
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class CometBlueNumberEntity(CometBlueBluetoothEntity, NumberEntity):
|
||||
"""Representation of a number."""
|
||||
|
||||
entity_description: CometBlueNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: CometBlueDataUpdateCoordinator,
|
||||
description: CometBlueNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize CometBlueNumberEntity."""
|
||||
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{coordinator.address}-{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.coordinator.data.temperatures.get(
|
||||
self.entity_description.cometblue_key
|
||||
)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the device."""
|
||||
|
||||
await self.coordinator.send_command(
|
||||
self.entity_description.set_fn(self.coordinator.device),
|
||||
{
|
||||
"values": {
|
||||
# manual temperature always needs to be set, otherwise TRV will turn OFF
|
||||
"manualTemp": self.coordinator.data.temperatures["manualTemp"],
|
||||
self.entity_description.cometblue_key: value,
|
||||
}
|
||||
},
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -35,6 +35,17 @@
|
||||
"sync_time": {
|
||||
"name": "Sync time"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"comfort_setpoint": {
|
||||
"name": "Comfort setpoint"
|
||||
},
|
||||
"eco_setpoint": {
|
||||
"name": "Eco setpoint"
|
||||
},
|
||||
"offset": {
|
||||
"name": "Setpoint offset"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_PRESET_MODE,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA,
|
||||
PRESET_NONE,
|
||||
@@ -451,6 +452,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
||||
return
|
||||
self._attr_preset_mode = self._presets_inv.get(temperature, PRESET_NONE)
|
||||
self._target_temp = temperature
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
return
|
||||
await self._async_control_heating(force=True)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -1201,6 +1201,17 @@ class TemperatureSettingTrait(_Trait):
|
||||
preset_to_google = {climate.PRESET_ECO: "eco"}
|
||||
google_to_preset = {value: key for key, value in preset_to_google.items()}
|
||||
|
||||
action_to_google = {
|
||||
climate.HVACAction.OFF: "off",
|
||||
climate.HVACAction.HEATING: "heat",
|
||||
climate.HVACAction.DEFROSTING: "heat",
|
||||
climate.HVACAction.PREHEATING: "heat",
|
||||
climate.HVACAction.COOLING: "cool",
|
||||
climate.HVACAction.DRYING: "dry",
|
||||
climate.HVACAction.FAN: "fan-only",
|
||||
climate.HVACAction.IDLE: "none",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features, device_class, _):
|
||||
"""Test if state is supported."""
|
||||
@@ -1284,6 +1295,11 @@ class TemperatureSettingTrait(_Trait):
|
||||
else:
|
||||
response["thermostatMode"] = self.hvac_to_google.get(operation, "none")
|
||||
|
||||
if (
|
||||
action := self.action_to_google.get(attrs.get(climate.ATTR_HVAC_ACTION))
|
||||
) is not None:
|
||||
response["activeThermostatMode"] = action
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
response["thermostatTemperatureAmbient"] = round(
|
||||
|
||||
@@ -172,7 +172,7 @@ def _format_tool(
|
||||
def _escape_decode(value: Any) -> Any:
|
||||
"""Recursively call codecs.escape_decode on all values."""
|
||||
if isinstance(value, str):
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined]
|
||||
return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8")
|
||||
if isinstance(value, list):
|
||||
return [_escape_decode(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorClient, SupervisorError
|
||||
from aiohasupervisor.models import (
|
||||
Folder,
|
||||
FullBackupOptions,
|
||||
FullRestoreOptions,
|
||||
PartialBackupOptions,
|
||||
@@ -70,6 +71,31 @@ SERVICE_MOUNT_RELOAD = "mount_reload"
|
||||
|
||||
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
|
||||
|
||||
# Legacy alias used by the Supervisor API for the homeassistant flag, kept
|
||||
# for backwards compatibility with existing automations.
|
||||
LEGACY_FOLDER_HOMEASSISTANT = "homeassistant"
|
||||
|
||||
|
||||
def _normalize_partial_options_data(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map legacy aliases used by both partial backup and partial restore handlers."""
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
if ATTR_FOLDERS in data:
|
||||
folders: set[Any] = set(data[ATTR_FOLDERS])
|
||||
if LEGACY_FOLDER_HOMEASSISTANT in folders:
|
||||
folders.discard(LEGACY_FOLDER_HOMEASSISTANT)
|
||||
if data.get(ATTR_HOMEASSISTANT) is False:
|
||||
raise ServiceValidationError(
|
||||
f"{ATTR_HOMEASSISTANT}=False conflicts with the legacy "
|
||||
f"{LEGACY_FOLDER_HOMEASSISTANT!r} entry in {ATTR_FOLDERS}"
|
||||
)
|
||||
data[ATTR_HOMEASSISTANT] = True
|
||||
if folders:
|
||||
data[ATTR_FOLDERS] = folders
|
||||
else:
|
||||
data.pop(ATTR_FOLDERS)
|
||||
return data
|
||||
|
||||
|
||||
def valid_addon(value: Any) -> str:
|
||||
"""Validate value is a valid addon slug."""
|
||||
@@ -113,7 +139,10 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -136,7 +165,10 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
|
||||
{
|
||||
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
|
||||
vol.Optional(ATTR_FOLDERS): vol.All(
|
||||
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
|
||||
cv.ensure_list,
|
||||
[vol.Any(LEGACY_FOLDER_HOMEASSISTANT, vol.Coerce(Folder))],
|
||||
vol.Unique(),
|
||||
vol.Coerce(set),
|
||||
),
|
||||
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
|
||||
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
|
||||
@@ -343,9 +375,7 @@ def async_register_backup_restore_services(
|
||||
service: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handler for create partial backup service. Returns the new backup's ID."""
|
||||
data = service.data.copy()
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(service.data.copy())
|
||||
options = PartialBackupOptions(**data)
|
||||
|
||||
try:
|
||||
@@ -392,8 +422,7 @@ def async_register_backup_restore_services(
|
||||
"""Handler for partial restore service."""
|
||||
data = service.data.copy()
|
||||
backup_slug = data.pop(ATTR_SLUG)
|
||||
if ATTR_APPS in data:
|
||||
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
|
||||
data = _normalize_partial_options_data(data)
|
||||
options = PartialRestoreOptions(**data)
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState
|
||||
from homematicip.base.enums import (
|
||||
BinaryBehaviorType,
|
||||
LockState,
|
||||
SmokeDetectorAlarmType,
|
||||
WindowState,
|
||||
)
|
||||
from homematicip.base.functionalChannels import MultiModeInputChannel
|
||||
from homematicip.device import (
|
||||
AccelerationSensor,
|
||||
@@ -352,7 +357,22 @@ class HomematicipFullFlushLockControllerLocked(
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if the controlled lock is locked."""
|
||||
"""Return true if the controlled lock is unlocked.
|
||||
|
||||
Per HA's BinarySensorDeviceClass.LOCK contract, ON means
|
||||
unlocked / open and OFF means locked / closed.
|
||||
|
||||
The mapping from the firmware-reported ``lockState`` depends on
|
||||
the channel's ``binaryBehaviorType``. With the default
|
||||
``NORMALLY_OPEN`` wiring, the input goes ACTIVE (and lockState
|
||||
flips to ``LOCKED``) when the contact closes — i.e. when a
|
||||
magnetic door contact registers the door as closed. With
|
||||
``NORMALLY_CLOSE`` the same physical event puts the input into
|
||||
the IDLE state (lockState ``UNLOCKED``). To present the same
|
||||
HA semantics regardless of which way the user wired the
|
||||
contact, ``lockState`` is interpreted relative to the
|
||||
configured behavior.
|
||||
"""
|
||||
channel = _get_channel_by_role(
|
||||
self._device,
|
||||
"MULTI_MODE_LOCK_INPUT_CHANNEL",
|
||||
@@ -361,7 +381,15 @@ class HomematicipFullFlushLockControllerLocked(
|
||||
if channel is None:
|
||||
return False
|
||||
lock_state = getattr(channel, "lockState", None)
|
||||
return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
|
||||
is_locked_state = (
|
||||
getattr(lock_state, "name", lock_state) == LockState.LOCKED.name
|
||||
)
|
||||
binary_behavior = getattr(channel, "binaryBehaviorType", None)
|
||||
normally_close = (
|
||||
getattr(binary_behavior, "name", binary_behavior)
|
||||
== BinaryBehaviorType.NORMALLY_CLOSE.name
|
||||
)
|
||||
return is_locked_state if normally_close else not is_locked_state
|
||||
|
||||
|
||||
class HomematicipFullFlushLockControllerGlassBreak(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import RadioFrequencyCommand
|
||||
from rf_protocols.codes.honeywell.string_lights import CODES
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import async_get_transmitters
|
||||
@@ -11,7 +12,6 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN
|
||||
from .light import COMMANDS
|
||||
|
||||
|
||||
class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -24,7 +24,7 @@ class HoneywellStringLightsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
sample_command: RadioFrequencyCommand = await self.hass.async_add_executor_job(
|
||||
COMMANDS.load_command, "turn_on"
|
||||
CODES.load_command, "turn_on"
|
||||
)
|
||||
try:
|
||||
transmitters = async_get_transmitters(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols import get_codes
|
||||
from rf_protocols.codes.honeywell.string_lights import CODES
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
@@ -16,8 +16,6 @@ from .entity import HoneywellStringLightsEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
COMMANDS = get_codes("honeywell/string_lights")
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -57,7 +55,7 @@ class HoneywellStringLight(HoneywellStringLightsEntity, LightEntity, RestoreEnti
|
||||
|
||||
async def _async_send_command(self, name: str) -> None:
|
||||
"""Load the named command and send it via the configured transmitter."""
|
||||
command = await COMMANDS.async_load_command(name)
|
||||
command = await CODES.async_load_command(name)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -33,14 +33,6 @@ SCAN_INTERVAL: Final = 30
|
||||
type IndevoltConfigEntry = ConfigEntry[IndevoltCoordinator]
|
||||
|
||||
|
||||
class DeviceTimeoutError(HomeAssistantError):
|
||||
"""Raised when device push times out."""
|
||||
|
||||
|
||||
class DeviceConnectionError(HomeAssistantError):
|
||||
"""Raised when device push fails due to connection issues."""
|
||||
|
||||
|
||||
class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Coordinator for fetching and pushing data to indevolt devices."""
|
||||
|
||||
@@ -96,12 +88,7 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_push_data(self, sensor_key: str, value: Any) -> bool:
|
||||
"""Push/write data values to given key on the device."""
|
||||
try:
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
except TimeoutError as err:
|
||||
raise DeviceTimeoutError(f"Device push timed out: {err}") from err
|
||||
except (ClientError, OSError) as err:
|
||||
raise DeviceConnectionError(f"Device push failed: {err}") from err
|
||||
return await self.api.set_data(sensor_key, value)
|
||||
|
||||
async def async_switch_energy_mode(
|
||||
self, target_mode: IndevoltEnergyMode, refresh: bool = True
|
||||
@@ -125,15 +112,9 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
# Switch energy mode if required
|
||||
if current_mode != target_mode:
|
||||
try:
|
||||
success = await self.async_push_data(
|
||||
IndevoltConfig.WRITE_ENERGY_MODE, target_mode
|
||||
)
|
||||
except (DeviceTimeoutError, DeviceConnectionError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_switch_energy_mode",
|
||||
) from err
|
||||
success = await self.async_push_data(
|
||||
IndevoltConfig.WRITE_ENERGY_MODE, target_mode
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HomeAssistantError(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["indevolt-api==1.7.1"]
|
||||
"requirements": ["indevolt-api==1.7.2"]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -138,4 +139,8 @@ class IndevoltNumberEntity(IndevoltEntity, NumberEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set value {int_value} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -60,7 +60,7 @@ rules:
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
@@ -69,6 +69,7 @@ rules:
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Integration represents a single device, not a hub with multiple devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -108,4 +109,8 @@ class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -354,6 +354,9 @@
|
||||
},
|
||||
"soc_below_minimum": {
|
||||
"message": "Target SOC ({target}%) is below the device minimum ({minimum_soc}%)"
|
||||
},
|
||||
"write_error": {
|
||||
"message": "Cannot update value for {name}"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import IndevoltConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IndevoltCoordinator
|
||||
from .entity import IndevoltEntity
|
||||
|
||||
@@ -128,4 +129,8 @@ class IndevoltSwitchEntity(IndevoltEntity, SwitchEntity):
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
else:
|
||||
raise HomeAssistantError(f"Failed to set value {value} for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="write_error",
|
||||
translation_placeholders={"name": str(self.name)},
|
||||
)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["infrared-protocols==4.0.0"]
|
||||
"requirements": ["infrared-protocols==5.1.0"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: KioskerConfigEntry) -> bool:
|
||||
|
||||
@@ -10,3 +10,4 @@ PORT = 8081
|
||||
POLL_INTERVAL = 15
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_SSL_VERIFY = False
|
||||
REFRESH_DELAY = 0.5
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"last_motion": {
|
||||
"default": "mdi:motion-sensor"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"default": "mdi:power-sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,11 @@
|
||||
"last_motion": {
|
||||
"name": "Last motion"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"disable_screensaver": {
|
||||
"name": "Disable screensaver"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Switch platform for Kiosker."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from kiosker import (
|
||||
AuthenticationError,
|
||||
BadRequestError,
|
||||
ConnectionError,
|
||||
IPAuthenticationError,
|
||||
KioskerAPI,
|
||||
TLSVerificationError,
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import KioskerConfigEntry
|
||||
from .const import REFRESH_DELAY
|
||||
from .coordinator import KioskerData
|
||||
from .entity import KioskerEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class KioskerSwitchEntityDescription(SwitchEntityDescription):
|
||||
"""Kiosker switch description."""
|
||||
|
||||
set_state_fn: Callable[[KioskerAPI, bool], None]
|
||||
is_on_fn: Callable[[KioskerData], bool | None]
|
||||
|
||||
|
||||
SWITCHES: tuple[KioskerSwitchEntityDescription, ...] = (
|
||||
KioskerSwitchEntityDescription(
|
||||
key="disableScreensaver",
|
||||
translation_key="disable_screensaver",
|
||||
set_state_fn=lambda api, disabled: api.screensaver_set_disabled_state(disabled),
|
||||
is_on_fn=lambda x: x.screensaver.disabled if x.screensaver else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: KioskerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Kiosker switches based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
KioskerSwitch(coordinator, description) for description in SWITCHES
|
||||
)
|
||||
|
||||
|
||||
class KioskerSwitch(KioskerEntity, SwitchEntity):
|
||||
"""Representation of a Kiosker switch."""
|
||||
|
||||
entity_description: KioskerSwitchEntityDescription
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the switch is on."""
|
||||
return self.entity_description.is_on_fn(self.coordinator.data)
|
||||
|
||||
async def _handle_method_call(self, state: bool) -> None:
|
||||
"""Handle method call with error handling."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.entity_description.set_state_fn, self.coordinator.api, state
|
||||
)
|
||||
except AuthenticationError as exc:
|
||||
raise HomeAssistantError("Authentication failed") from exc
|
||||
except IPAuthenticationError as exc:
|
||||
raise HomeAssistantError("IP Authentication failed") from exc
|
||||
except ConnectionError as exc:
|
||||
raise HomeAssistantError(f"Connection failed: {exc}") from exc
|
||||
except TLSVerificationError as exc:
|
||||
raise HomeAssistantError(f"TLS verification failed: {exc}") from exc
|
||||
except BadRequestError as exc:
|
||||
raise ServiceValidationError(f"Bad request: {exc}") from exc
|
||||
|
||||
await asyncio.sleep(REFRESH_DELAY)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_on(self, **_kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
await self._handle_method_call(True)
|
||||
|
||||
async def async_turn_off(self, **_kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
await self._handle_method_call(False)
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to setup the Lunatone device with {url}?"
|
||||
"description": "Do you want to set up the Lunatone device at {url}?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
|
||||
@@ -54,6 +54,7 @@ from .client import (
|
||||
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
|
||||
from .config_integration import CONFIG_SCHEMA_BASE
|
||||
from .const import (
|
||||
ATTR_MESSAGE_EXPIRY_INTERVAL,
|
||||
ATTR_PAYLOAD,
|
||||
ATTR_QOS,
|
||||
ATTR_RETAIN,
|
||||
@@ -246,6 +247,7 @@ MQTT_PUBLISH_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_EVALUATE_PAYLOAD): cv.boolean,
|
||||
vol.Optional(ATTR_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||
vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
vol.Optional(ATTR_MESSAGE_EXPIRY_INTERVAL): cv.positive_time_period_dict,
|
||||
},
|
||||
required=True,
|
||||
)
|
||||
@@ -349,12 +351,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
evaluate_payload: bool = call.data.get(ATTR_EVALUATE_PAYLOAD, False)
|
||||
qos: int = call.data[ATTR_QOS]
|
||||
retain: bool = call.data[ATTR_RETAIN]
|
||||
message_expiry_interval: int | None = (
|
||||
int(call.data[ATTR_MESSAGE_EXPIRY_INTERVAL].total_seconds())
|
||||
if ATTR_MESSAGE_EXPIRY_INTERVAL in call.data
|
||||
else None
|
||||
)
|
||||
|
||||
if evaluate_payload:
|
||||
# Convert quoted binary literal to raw data
|
||||
payload = convert_outgoing_mqtt_payload(payload)
|
||||
|
||||
await mqtt_data.client.async_publish(msg_topic, payload, qos, retain)
|
||||
await mqtt_data.client.async_publish(
|
||||
msg_topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
message_expiry_interval=message_expiry_interval,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_PUBLISH, async_publish_service, schema=MQTT_PUBLISH_SCHEMA
|
||||
|
||||
@@ -37,7 +37,7 @@ from homeassistant.core import (
|
||||
callback,
|
||||
get_hassjob_callable_job_type,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
@@ -128,9 +128,21 @@ def publish(
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
*,
|
||||
message_expiry_interval: int | None = None,
|
||||
) -> None:
|
||||
"""Publish message to a MQTT topic."""
|
||||
hass.create_task(async_publish(hass, topic, payload, qos, retain, encoding))
|
||||
hass.create_task(
|
||||
async_publish(
|
||||
hass,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
encoding,
|
||||
message_expiry_interval=message_expiry_interval,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_publish(
|
||||
@@ -140,6 +152,8 @@ async def async_publish(
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
*,
|
||||
message_expiry_interval: int | None = None,
|
||||
) -> None:
|
||||
"""Publish message to a MQTT topic."""
|
||||
if not mqtt_config_entry_enabled(hass):
|
||||
@@ -193,7 +207,13 @@ async def async_publish(
|
||||
qos = qos or 0
|
||||
retain = retain or False
|
||||
|
||||
await mqtt_data.client.async_publish(topic, outgoing_payload, qos, retain)
|
||||
await mqtt_data.client.async_publish(
|
||||
topic,
|
||||
outgoing_payload,
|
||||
qos,
|
||||
retain,
|
||||
message_expiry_interval=message_expiry_interval,
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
@@ -692,17 +712,37 @@ class MQTT:
|
||||
return topic in self._pending_subscriptions
|
||||
|
||||
async def async_publish(
|
||||
self, topic: str, payload: PublishPayloadType, qos: int, retain: bool
|
||||
self,
|
||||
topic: str,
|
||||
payload: PublishPayloadType,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
*,
|
||||
message_expiry_interval: int | None = None,
|
||||
) -> None:
|
||||
"""Publish a MQTT message."""
|
||||
msg_info = self._mqttc.publish(topic, payload, qos, retain)
|
||||
properties = mqtt.Properties(mqtt.PacketTypes.PUBLISH) # type: ignore[no-untyped-call]
|
||||
if message_expiry_interval is not None:
|
||||
if not self.is_mqttv5:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="mqtt_message_expiry_interval_not_supported",
|
||||
translation_placeholders={
|
||||
"topic": topic,
|
||||
"protocol": self.conf.get(CONF_PROTOCOL, PROTOCOL_311),
|
||||
},
|
||||
)
|
||||
properties.MessageExpiryInterval = message_expiry_interval
|
||||
msg_info = self._mqttc.publish(topic, payload, qos, retain, properties)
|
||||
_LOGGER.debug(
|
||||
"Transmitting%s message on %s: '%s', mid: %s, qos: %s",
|
||||
"Transmitting%s message on %s: '%s', mid: %s, qos: %s,"
|
||||
" message_expiry_interval: %s",
|
||||
" retained" if retain else "",
|
||||
topic,
|
||||
payload,
|
||||
msg_info.mid,
|
||||
qos,
|
||||
message_expiry_interval,
|
||||
)
|
||||
await self._async_wait_for_mid_or_raise(msg_info.mid, msg_info.rc)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.exceptions import TemplateError
|
||||
ATTR_DISCOVERY_HASH = "discovery_hash"
|
||||
ATTR_DISCOVERY_PAYLOAD = "discovery_payload"
|
||||
ATTR_DISCOVERY_TOPIC = "discovery_topic"
|
||||
ATTR_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_QOS = "qos"
|
||||
ATTR_RETAIN = "retain"
|
||||
|
||||
@@ -342,9 +342,11 @@ def _merge_common_device_options(
|
||||
CONF_AVAILABILITY_TEMPLATE,
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_QOS
|
||||
Common options in the body of the device based config are inherited into
|
||||
the component. Unless the option is explicitly specified at component level,
|
||||
in that case the option at component level will override the common option.
|
||||
|
||||
@@ -65,9 +65,11 @@ SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY_TEMPLATE,
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
CONF_QOS,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ publish:
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
message_expiry_interval:
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
dump:
|
||||
fields:
|
||||
topic:
|
||||
|
||||
@@ -1099,6 +1099,9 @@
|
||||
"mqtt_broker_error": {
|
||||
"message": "Error talking to MQTT: {error_message}."
|
||||
},
|
||||
"mqtt_message_expiry_interval_not_supported": {
|
||||
"message": "Publishing to topic {topic} with a Message Expiry Interval is not supported for protocol version {protocol}."
|
||||
},
|
||||
"mqtt_not_setup_cannot_publish": {
|
||||
"message": "Cannot publish to topic \"{topic}\", make sure MQTT is set up correctly."
|
||||
},
|
||||
@@ -1546,6 +1549,10 @@
|
||||
"description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data.",
|
||||
"name": "Evaluate payload"
|
||||
},
|
||||
"message_expiry_interval": {
|
||||
"description": "Expires a publish message after the interval in case the device subscriber was temporarily offline, or the message was set as a retained message. Only supported with MQTT protocol version 5.0.",
|
||||
"name": "Message Expiry Interval"
|
||||
},
|
||||
"payload": {
|
||||
"description": "The payload to publish. Publishes an empty message if not provided.",
|
||||
"name": "Payload"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: todo
|
||||
comment: |
|
||||
Alarm actions (arm/disarm/trigger) currently call client methods directly
|
||||
without integration-level exception handling.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable:
|
||||
status: todo
|
||||
comment: Binary sensor initial state should be None (unknown) instead of False.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
Switch from MockClient to proper AsyncMock.
|
||||
More tests in test_init.py should use mock_config_entry fixture.
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: done
|
||||
comment: |
|
||||
Binary sensors linked to main device via via_device.
|
||||
Consider improving services to not just pick the first config entry.
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: No entities need a non-default category.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: Entities use device name as entity name.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No entity icons are used.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -5,6 +5,8 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pynintendoparental.player import Player
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -26,20 +28,30 @@ class NintendoParentalControlsSensor(StrEnum):
|
||||
"""Store keys for Nintendo parental controls sensors."""
|
||||
|
||||
PLAYING_TIME = "playing_time"
|
||||
PLAYER_PLAYING_TIME = "player_playing_time"
|
||||
TIME_REMAINING = "time_remaining"
|
||||
TIME_EXTENDED = "time_extended"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class NintendoParentalControlsSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description for Nintendo parental controls sensor entities."""
|
||||
class NintendoParentalControlsDeviceSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description for Nintendo parental controls device sensor entities."""
|
||||
|
||||
value_fn: Callable[[Device], datetime | int | float | None]
|
||||
available_fn: Callable[[Device], bool] = lambda device: True
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...] = (
|
||||
NintendoParentalControlsSensorEntityDescription(
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class NintendoParentalControlsPlayerSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description for Nintendo parental controls player sensor entities."""
|
||||
|
||||
value_fn: Callable[[Player], int | float | None]
|
||||
|
||||
|
||||
DEVICE_SENSOR_DESCRIPTIONS: tuple[
|
||||
NintendoParentalControlsDeviceSensorEntityDescription, ...
|
||||
] = (
|
||||
NintendoParentalControlsDeviceSensorEntityDescription(
|
||||
key=NintendoParentalControlsSensor.PLAYING_TIME,
|
||||
translation_key=NintendoParentalControlsSensor.PLAYING_TIME,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
@@ -47,7 +59,7 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...]
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.today_playing_time,
|
||||
),
|
||||
NintendoParentalControlsSensorEntityDescription(
|
||||
NintendoParentalControlsDeviceSensorEntityDescription(
|
||||
key=NintendoParentalControlsSensor.TIME_REMAINING,
|
||||
translation_key=NintendoParentalControlsSensor.TIME_REMAINING,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
@@ -55,7 +67,7 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...]
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda device: device.today_time_remaining,
|
||||
),
|
||||
NintendoParentalControlsSensorEntityDescription(
|
||||
NintendoParentalControlsDeviceSensorEntityDescription(
|
||||
key=NintendoParentalControlsSensor.TIME_EXTENDED,
|
||||
translation_key=NintendoParentalControlsSensor.TIME_EXTENDED,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
@@ -66,30 +78,53 @@ SENSOR_DESCRIPTIONS: tuple[NintendoParentalControlsSensorEntityDescription, ...]
|
||||
),
|
||||
)
|
||||
|
||||
PLAYER_SENSOR_DESCRIPTIONS: tuple[
|
||||
NintendoParentalControlsPlayerSensorEntityDescription, ...
|
||||
] = (
|
||||
NintendoParentalControlsPlayerSensorEntityDescription(
|
||||
key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME,
|
||||
translation_key=NintendoParentalControlsSensor.PLAYER_PLAYING_TIME,
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda player: player.playing_time,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: NintendoParentalControlsConfigEntry,
|
||||
async_add_devices: AddConfigEntryEntitiesCallback,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
async_add_devices(
|
||||
NintendoParentalControlsSensorEntity(entry.runtime_data, device, sensor)
|
||||
entities: list[NintendoDevice] = []
|
||||
entities.extend(
|
||||
NintendoParentalControlsDeviceSensorEntity(entry.runtime_data, device, sensor)
|
||||
for device in entry.runtime_data.api.devices.values()
|
||||
for sensor in SENSOR_DESCRIPTIONS
|
||||
for sensor in DEVICE_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
for device in entry.runtime_data.api.devices.values():
|
||||
entities.extend(
|
||||
NintendoParentalControlsPlayerSensorEntity(
|
||||
entry.runtime_data, device, player_id, sensor
|
||||
)
|
||||
for player_id in device.players
|
||||
for sensor in PLAYER_SENSOR_DESCRIPTIONS
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity):
|
||||
class NintendoParentalControlsDeviceSensorEntity(NintendoDevice, SensorEntity):
|
||||
"""Represent a single sensor."""
|
||||
|
||||
entity_description: NintendoParentalControlsSensorEntityDescription
|
||||
entity_description: NintendoParentalControlsDeviceSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NintendoUpdateCoordinator,
|
||||
device: Device,
|
||||
description: NintendoParentalControlsSensorEntityDescription,
|
||||
description: NintendoParentalControlsDeviceSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator, device=device, key=description.key)
|
||||
@@ -104,3 +139,39 @@ class NintendoParentalControlsSensorEntity(NintendoDevice, SensorEntity):
|
||||
def available(self) -> bool:
|
||||
"""Return if the sensor is available."""
|
||||
return super().available and self.entity_description.available_fn(self._device)
|
||||
|
||||
|
||||
class NintendoParentalControlsPlayerSensorEntity(NintendoDevice, SensorEntity):
|
||||
"""Represent a single player sensor."""
|
||||
|
||||
entity_description: NintendoParentalControlsPlayerSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: NintendoUpdateCoordinator,
|
||||
device: Device,
|
||||
player_id: str,
|
||||
description: NintendoParentalControlsPlayerSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator, device=device, key=description.key)
|
||||
self.entity_description = description
|
||||
self.player_id = player_id
|
||||
player_obj = device.get_player(player_id)
|
||||
nickname = player_obj.nickname or ""
|
||||
self._attr_translation_placeholders = {"nickname": nickname}
|
||||
self._attr_unique_id = f"{device.device_id}_{player_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the entity picture."""
|
||||
if self.player_id not in self._device.players:
|
||||
return None
|
||||
return self._device.get_player(self.player_id).player_image
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
"""Return the native value."""
|
||||
if self.player_id not in self._device.players:
|
||||
return None
|
||||
return self.entity_description.value_fn(self._device.get_player(self.player_id))
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"player_playing_time": {
|
||||
"name": "{nickname} used screen time"
|
||||
},
|
||||
"playing_time": {
|
||||
"name": "Used screen time"
|
||||
},
|
||||
|
||||
@@ -103,14 +103,14 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity):
|
||||
"""Week profile selector for Nobø zones."""
|
||||
|
||||
_attr_translation_key = "week_profile"
|
||||
_profiles: dict[int, str] = {}
|
||||
_attr_options: list[str] = []
|
||||
_attr_current_option: str | None = None
|
||||
|
||||
def __init__(self, zone_id: str, hub: nobo) -> None:
|
||||
"""Initialize the week profile selector."""
|
||||
super().__init__(hub)
|
||||
self._id = zone_id
|
||||
self._profiles: dict[str, str] = {}
|
||||
self._attr_options: list[str] = []
|
||||
self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:profile"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")},
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
"""Helpers for loading Novy cooker-hood RF commands."""
|
||||
"""Command names for the Novy Cooker Hood RF codes."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from rf_protocols import CodeCollection, get_codes
|
||||
|
||||
COMMAND_LIGHT: Final = "light"
|
||||
COMMAND_PLUS: Final = "plus"
|
||||
COMMAND_MINUS: Final = "minus"
|
||||
|
||||
|
||||
def get_codes_for_code(code: int) -> CodeCollection:
|
||||
"""Return the bundled `rf-protocols` collection for a Novy cooker-hood code."""
|
||||
return get_codes(f"novy/cooker_hood/code_{code}")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
@@ -17,7 +18,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .commands import COMMAND_LIGHT, get_codes_for_code
|
||||
from .commands import COMMAND_LIGHT
|
||||
from .const import (
|
||||
CODE_MAX,
|
||||
CODE_MIN,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
|
||||
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -14,7 +16,7 @@ from homeassistant.util.percentage import (
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
|
||||
from .commands import COMMAND_MINUS, COMMAND_PLUS, get_codes_for_code
|
||||
from .commands import COMMAND_MINUS, COMMAND_PLUS
|
||||
from .const import CONF_CODE, SPEED_COUNT
|
||||
from .entity import NovyCookerHoodEntity
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -10,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .commands import COMMAND_LIGHT, get_codes_for_code
|
||||
from .commands import COMMAND_LIGHT
|
||||
from .const import CONF_CODE
|
||||
from .entity import NovyCookerHoodEntity
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["pyzbar"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["Pillow==12.2.0", "pyzbar==0.1.7"]
|
||||
"requirements": ["Pillow==12.2.0", "pyzbar==0.1.9"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["renault_api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["renault-api==0.5.7"]
|
||||
"requirements": ["renault-api==0.5.8"]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"codeowners": ["@fabaff"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/serial",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["serialx==1.7.2"]
|
||||
"requirements": ["serialx==1.7.3"]
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@ class ShellyRpcMediaPlayer(ShellyRpcAttributeEntity, MediaPlayerEntity):
|
||||
|
||||
return self._last_media_position_updated_at
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return image of the media playing."""
|
||||
if not self.available:
|
||||
return None
|
||||
|
||||
return super().entity_picture
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the image URL of current playing media."""
|
||||
|
||||
@@ -20,8 +20,8 @@ class SlideEntity(CoordinatorEntity[SlideCoordinator]):
|
||||
manufacturer="Innovation in Motion",
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data["mac"])},
|
||||
name=coordinator.data["device_name"],
|
||||
sw_version=coordinator.api_version,
|
||||
hw_version=coordinator.data["board_rev"],
|
||||
sw_version=str(coordinator.api_version),
|
||||
hw_version=str(coordinator.data["board_rev"]),
|
||||
serial_number=coordinator.data["mac"],
|
||||
configuration_url=f"http://{coordinator.host}",
|
||||
)
|
||||
|
||||
@@ -302,6 +302,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
|
||||
)
|
||||
or []
|
||||
)
|
||||
# If "off" is in supported levels, the switch doesn't control the lamp
|
||||
self._use_switch = "off" not in levels
|
||||
color_modes = set()
|
||||
if "off" not in levels or len(levels) > 2:
|
||||
color_modes.add(ColorMode.BRIGHTNESS)
|
||||
@@ -316,7 +318,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
await self.async_set_level(kwargs[ATTR_BRIGHTNESS])
|
||||
return
|
||||
if self.supports_capability(Capability.SWITCH):
|
||||
if self._use_switch and self.supports_capability(Capability.SWITCH):
|
||||
await self.execute_device_command(Capability.SWITCH, Command.ON)
|
||||
# if no switch, turn on via brightness level
|
||||
else:
|
||||
@@ -324,7 +326,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the lamp off."""
|
||||
if self.supports_capability(Capability.SWITCH):
|
||||
if self._use_switch and self.supports_capability(Capability.SWITCH):
|
||||
await self.execute_device_command(Capability.SWITCH, Command.OFF)
|
||||
return
|
||||
await self.execute_device_command(
|
||||
@@ -354,7 +356,8 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
|
||||
)
|
||||
# turn on switch separately if needed
|
||||
if (
|
||||
self.supports_capability(Capability.SWITCH)
|
||||
self._use_switch
|
||||
and self.supports_capability(Capability.SWITCH)
|
||||
and not self.is_on
|
||||
and brightness > 0
|
||||
):
|
||||
@@ -385,7 +388,7 @@ class SmartThingsLamp(SmartThingsEntity, LightEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if lamp is on."""
|
||||
if self.supports_capability(Capability.SWITCH):
|
||||
if self._use_switch and self.supports_capability(Capability.SWITCH):
|
||||
state = self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
@@ -36,8 +36,8 @@ class SmartyCoordinator(DataUpdateCoordinator[None]):
|
||||
async def _async_setup(self) -> None:
|
||||
if not await self.hass.async_add_executor_job(self.client.update):
|
||||
raise UpdateFailed("Failed to update Smarty data")
|
||||
self.software_version = self.client.get_software_version()
|
||||
self.configuration_version = self.client.get_configuration_version()
|
||||
self.software_version = str(self.client.get_software_version())
|
||||
self.configuration_version = str(self.client.get_configuration_version())
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch data from Smarty."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
@@ -59,6 +60,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
try:
|
||||
device_info = await client.get_device_info()
|
||||
auth_valid = await client.validate_credentials()
|
||||
device_id = device_info.device_identifier
|
||||
if auth_valid and device_id is None:
|
||||
system_info = await client.get_system_info()
|
||||
device_id = system_info.mnf_info.serial
|
||||
except TeltonikaConnectionError as err:
|
||||
_LOGGER.debug(
|
||||
"Failed to connect to Teltonika device at %s: %s", base_url, err
|
||||
@@ -76,7 +81,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
|
||||
return {
|
||||
"title": device_info.device_name,
|
||||
"device_id": device_info.device_identifier,
|
||||
"device_id": device_id,
|
||||
"host": base_url,
|
||||
}
|
||||
|
||||
@@ -220,8 +225,29 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# No URL variant worked, device not reachable, don't autodiscover
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
# Set unique ID and check for existing conf
|
||||
await self.async_set_unique_id(device_id)
|
||||
formatted_mac = dr.format_mac(discovery_info.macaddress)
|
||||
|
||||
if device_id is None:
|
||||
# FW with API v1.0 doesn't expose any unique identifier on the
|
||||
# unauthorized endpoint. Match existing entries by MAC so it
|
||||
# aborts without asking for credentials again.
|
||||
device_reg = dr.async_get(self.hass)
|
||||
if existing := device_reg.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)}
|
||||
):
|
||||
for entry_id in existing.config_entries:
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if (
|
||||
entry is not None
|
||||
and entry.domain == DOMAIN
|
||||
and entry.unique_id is not None
|
||||
):
|
||||
device_id = entry.unique_id
|
||||
break
|
||||
|
||||
# Use the MAC as a placeholder unique_id when nothing matched, so
|
||||
# parallel DHCP advertisements don't both reach dhcp_confirm.
|
||||
await self.async_set_unique_id(device_id or formatted_mac)
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
|
||||
|
||||
# Store discovery info for the user step
|
||||
@@ -243,21 +269,27 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
# Get the host from the discovery
|
||||
host = getattr(self, "_discovered_host", "")
|
||||
|
||||
data = {
|
||||
CONF_HOST: host,
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_VERIFY_SSL: False,
|
||||
}
|
||||
try:
|
||||
# Validate credentials with discovered host
|
||||
data = {
|
||||
CONF_HOST: host,
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_VERIFY_SSL: False,
|
||||
}
|
||||
info = await validate_input(self.hass, data)
|
||||
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during DHCP confirm")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
# Update unique ID to device identifier if we didn't get it during discovery
|
||||
await self.async_set_unique_id(
|
||||
info["device_id"], raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: info["host"]})
|
||||
|
||||
return self.async_create_entry(
|
||||
title=info["title"],
|
||||
@@ -268,13 +300,6 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during DHCP confirm")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="dhcp_confirm",
|
||||
|
||||
@@ -11,7 +11,11 @@ from teltasync.modems import Modems
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -73,6 +77,14 @@ class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# Store device info for use by entities
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, system_info_response.mnf_info.serial)},
|
||||
connections={
|
||||
(CONNECTION_NETWORK_MAC, format_mac(mac))
|
||||
for mac in (
|
||||
system_info_response.mnf_info.mac_eth,
|
||||
system_info_response.mnf_info.mac,
|
||||
)
|
||||
if mac
|
||||
},
|
||||
name=system_info_response.static.device_name,
|
||||
manufacturer="Teltonika",
|
||||
model=system_info_response.static.model,
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["teltasync==0.2.0"]
|
||||
"requirements": ["teltasync==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom service actions in async_setup.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: Review feedback requested stronger config flow test coverage for domain registration errors and removal of unnecessary translation mocking where possible.
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Review feedback questioned whether the custom OAuth flow implementation can be simplified, including whether `CONFIG_SCHEMA` and the custom `OAuth2FlowHandler` are both needed.
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not provide custom service actions beyond standard entity services.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: todo
|
||||
comment: Coordinator raises UpdateFailed but does not implement the full log-once pattern (log when becoming unavailable, log when recovering).
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Review feedback requested test cleanup follow-ups, including patching API objects where they are used, preferring direct asserts over the `test_climate_offline` snapshot where appropriate, removing an unnecessary `async_setup_component(...)`, and avoiding direct `entry.runtime_data` assertions in unload tests.
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration does not implement device discovery mechanisms.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Tesla Fleet API requires OAuth authentication and cannot be automatically discovered.
|
||||
docs-data-update: done
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: Documentation includes NGINX configuration examples but lacks automation use case examples.
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: Documentation does not include explicit use case scenarios.
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: todo
|
||||
comment: PR already raised to fix these
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: todo
|
||||
comment: Integration does not implement async_step_reconfigure for updating settings without removal.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration does not have scenarios requiring user-actionable repair issues.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: Integration does not automatically remove devices that are no longer present in the Tesla account.
|
||||
# Platinum tier
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Integration is not yet registered in .strict-typing file and needs comprehensive type annotation review.
|
||||
@@ -6,7 +6,11 @@ from tuya_device_handlers.definition.button import (
|
||||
)
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
ButtonEntity,
|
||||
ButtonEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -63,6 +67,13 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.SP: (
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.DEVICE_RESTART,
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -696,6 +696,7 @@ class DPCode(StrEnum):
|
||||
DEHUMIDITY_SET_VALUE = "dehumidify_set_value"
|
||||
DELAY_CLEAN_TIME = "delay_clean_time"
|
||||
DELAY_SET = "delay_set"
|
||||
DEVICE_RESTART = "device_restart"
|
||||
DEW_POINT_TEMP = "dew_point_temp"
|
||||
DISINFECTION = "disinfection"
|
||||
DO_NOT_DISTURB = "do_not_disturb"
|
||||
@@ -751,6 +752,10 @@ class DPCode(StrEnum):
|
||||
HUMIDITY_VALUE = "humidity_value" # Humidity
|
||||
INSTALLATION_HEIGHT = "installation_height"
|
||||
INVERTER_OUTPUT_POWER = "inverter_output_power"
|
||||
IPC_AUTO_SIREN = "ipc_auto_siren"
|
||||
IPC_BRIGHT = "ipc_bright"
|
||||
IPC_CONTRAST = "ipc_contrast"
|
||||
IPC_SHARP = "ipc_sharp"
|
||||
IPC_WORK_MODE = "ipc_work_mode"
|
||||
LED_TYPE_1 = "led_type_1"
|
||||
LED_TYPE_2 = "led_type_2"
|
||||
@@ -776,6 +781,7 @@ class DPCode(StrEnum):
|
||||
MINI_SET = "mini_set"
|
||||
MODE = "mode" # Working mode / Mode
|
||||
MOODLIGHTING = "moodlighting" # Mood light
|
||||
MOTION_AREA_SWITCH = "motion_area_switch" # Activity area
|
||||
MOTION_RECORD = "motion_record"
|
||||
MOTION_SENSITIVITY = "motion_sensitivity"
|
||||
MOTION_SWITCH = "motion_switch" # Motion switch
|
||||
@@ -947,6 +953,7 @@ class DPCode(StrEnum):
|
||||
UP_DOWN = "up_down"
|
||||
UPPER_TEMP = "upper_temp"
|
||||
UPPER_TEMP_F = "upper_temp_f"
|
||||
USE_TIME_ONE = "use_time_one"
|
||||
UV = "uv" # UV sterilization
|
||||
UV_INDEX = "uv_index"
|
||||
UV_RUNTIME = "uv_runtime" # UV runtime
|
||||
@@ -979,6 +986,7 @@ class DPCode(StrEnum):
|
||||
WIRELESS_ELECTRICITY = "wireless_electricity"
|
||||
WORK_MODE = "work_mode" # Working mode
|
||||
WORK_POWER = "work_power"
|
||||
WORK_STATE = "work_state"
|
||||
WORK_STATE_E = "work_state_e"
|
||||
|
||||
|
||||
|
||||
@@ -228,7 +228,14 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
|
||||
),
|
||||
),
|
||||
DeviceCategory.SFKZQ: (
|
||||
# Controls the irrigation duration for the water valve
|
||||
# Controls the irrigation duration for indexed water valves
|
||||
NumberEntityDescription(
|
||||
key=DPCode.COUNTDOWN,
|
||||
translation_key="irrigation_duration",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
# Controls the irrigation duration for indexed water valves
|
||||
NumberEntityDescription(
|
||||
key=DPCode.COUNTDOWN_1,
|
||||
translation_key="indexed_irrigation_duration",
|
||||
@@ -299,6 +306,21 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
|
||||
translation_key="volume",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=DPCode.IPC_BRIGHT,
|
||||
translation_key="video_brightness",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=DPCode.IPC_CONTRAST,
|
||||
translation_key="video_contrast",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=DPCode.IPC_SHARP,
|
||||
translation_key="video_sharpness",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.SZJQR: (
|
||||
NumberEntityDescription(
|
||||
|
||||
@@ -1077,6 +1077,17 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.USE_TIME_ONE,
|
||||
translation_key="last_watering_time",
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.WORK_STATE,
|
||||
translation_key="irrigation_status",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
*BATTERY_SENSORS,
|
||||
),
|
||||
DeviceCategory.SGBJ: BATTERY_SENSORS,
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"inverter_output_power_limit": {
|
||||
"name": "Inverter output power limit"
|
||||
},
|
||||
"irrigation_duration": {
|
||||
"name": "Irrigation duration"
|
||||
},
|
||||
"maximum_brightness": {
|
||||
"name": "Maximum brightness"
|
||||
},
|
||||
@@ -256,6 +259,15 @@
|
||||
"time": {
|
||||
"name": "Time"
|
||||
},
|
||||
"video_brightness": {
|
||||
"name": "Video brightness"
|
||||
},
|
||||
"video_contrast": {
|
||||
"name": "Video contrast"
|
||||
},
|
||||
"video_sharpness": {
|
||||
"name": "Video sharpness"
|
||||
},
|
||||
"voice_times": {
|
||||
"name": "Voice times"
|
||||
},
|
||||
@@ -706,12 +718,23 @@
|
||||
"inverter_output_power": {
|
||||
"name": "Inverter output power"
|
||||
},
|
||||
"irrigation_status": {
|
||||
"name": "Status",
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"manual": "[%key:common::state::manual%]"
|
||||
}
|
||||
},
|
||||
"last_amount": {
|
||||
"name": "Last amount"
|
||||
},
|
||||
"last_operation_duration": {
|
||||
"name": "Last operation duration"
|
||||
},
|
||||
"last_watering_time": {
|
||||
"name": "Last watering time"
|
||||
},
|
||||
"lifetime_battery_charge_energy": {
|
||||
"name": "Lifetime battery charge energy"
|
||||
},
|
||||
@@ -932,6 +955,9 @@
|
||||
"auto_clean": {
|
||||
"name": "Auto clean"
|
||||
},
|
||||
"auto_siren": {
|
||||
"name": "Auto-trigger siren"
|
||||
},
|
||||
"battery_lock": {
|
||||
"name": "Battery lock"
|
||||
},
|
||||
@@ -986,6 +1012,9 @@
|
||||
"motion_alarm": {
|
||||
"name": "Motion alarm"
|
||||
},
|
||||
"motion_detection_zone": {
|
||||
"name": "Use motion detection zone"
|
||||
},
|
||||
"motion_recording": {
|
||||
"name": "Motion recording"
|
||||
},
|
||||
|
||||
@@ -717,6 +717,16 @@ SWITCHES: dict[DeviceCategory, tuple[SwitchEntityDescription, ...]] = {
|
||||
translation_key="motion_alarm",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=DPCode.MOTION_AREA_SWITCH,
|
||||
translation_key="motion_detection_zone",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=DPCode.IPC_AUTO_SIREN,
|
||||
translation_key="auto_siren",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.SZ: (
|
||||
SwitchEntityDescription(
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyuptimerobot"],
|
||||
"quality_scale": "gold",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyuptimerobot==25.0.0"]
|
||||
}
|
||||
|
||||
@@ -73,6 +73,4 @@ rules:
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Requirement 'pyuptimerobot==22.2.0' appears untyped
|
||||
strict-typing: done
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.2"]
|
||||
"requirements": ["aiousbwatcher==1.1.2", "serialx==1.7.3"]
|
||||
}
|
||||
|
||||
@@ -984,6 +984,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_getter=lambda api: api.getTemperature(),
|
||||
),
|
||||
ViCareSensorEntityDescription(
|
||||
key="target_temperature",
|
||||
translation_key="target_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_getter=lambda api: api.getTargetTemperature(),
|
||||
),
|
||||
ViCareSensorEntityDescription(
|
||||
key="room_humidity",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
|
||||
@@ -604,6 +604,9 @@
|
||||
"supply_temperature": {
|
||||
"name": "Supply temperature"
|
||||
},
|
||||
"target_temperature": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"valve_position": {
|
||||
"name": "Valve position"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiovodafone"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiovodafone==3.1.3"]
|
||||
"requirements": ["aiovodafone==3.2.0"]
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, SUPPORTED_DEVICE_TYPES
|
||||
from .coordinator import (
|
||||
@@ -21,11 +26,20 @@ from .coordinator import (
|
||||
WattsVisionDeviceData,
|
||||
WattsVisionHubCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Watts Vision component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class WattsVisionRuntimeData:
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
"""Climate platform for Watts Vision integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from visionpluspython.models import ThermostatDevice
|
||||
from visionpluspython.exceptions import WattsVisionError
|
||||
from visionpluspython.models import ThermostatDevice, ThermostatMode
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import WattsVisionConfigEntry
|
||||
from .const import DOMAIN, HVAC_MODE_TO_THERMOSTAT, THERMOSTAT_MODE_TO_HVAC
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
HVAC_ACTION_TO_HA,
|
||||
HVAC_MODE_TO_THERMOSTAT,
|
||||
PRESET_MODE_TO_THERMOSTAT,
|
||||
PRESET_MODES,
|
||||
THERMOSTAT_MODE_TO_HVAC,
|
||||
THERMOSTAT_MODE_TO_PRESET,
|
||||
)
|
||||
from .coordinator import WattsVisionDeviceCoordinator
|
||||
from .entity import WattsVisionEntity
|
||||
|
||||
@@ -26,6 +37,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
def _parse_thermostat_mode(mode: str) -> ThermostatMode:
|
||||
return ThermostatMode[mode.upper()]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: WattsVisionConfigEntry,
|
||||
@@ -79,9 +94,13 @@ async def async_setup_entry(
|
||||
class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
|
||||
"""Representation of a Watts Vision heater as a climate entity."""
|
||||
|
||||
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF, HVACMode.AUTO]
|
||||
_attr_preset_modes = PRESET_MODES
|
||||
_attr_name = None
|
||||
_attr_translation_key = "thermostat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -112,7 +131,44 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return hvac mode."""
|
||||
return THERMOSTAT_MODE_TO_HVAC.get(self.device.thermostat_mode)
|
||||
return THERMOSTAT_MODE_TO_HVAC.get(
|
||||
_parse_thermostat_mode(self.device.thermostat_mode)
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current HVAC action."""
|
||||
return HVAC_ACTION_TO_HA.get(self.device.hvac_action)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return THERMOSTAT_MODE_TO_PRESET.get(
|
||||
_parse_thermostat_mode(self.device.thermostat_mode)
|
||||
)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
mode = PRESET_MODE_TO_THERMOSTAT[preset_mode]
|
||||
|
||||
try:
|
||||
await self.coordinator.client.set_thermostat_mode(self.device_id, mode)
|
||||
except (ValueError, RuntimeError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_preset_mode_error",
|
||||
) from err
|
||||
|
||||
_LOGGER.debug(
|
||||
"Successfully set preset mode to %s (ThermostatMode.%s) for %s",
|
||||
preset_mode,
|
||||
mode.name,
|
||||
self.device_id,
|
||||
)
|
||||
|
||||
self.coordinator.trigger_fast_polling()
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
@@ -140,6 +196,47 @@ class WattsVisionClimate(WattsVisionEntity[ThermostatDevice], ClimateEntity):
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_activate_timer_mode(
|
||||
self, temperature: float, duration: timedelta
|
||||
) -> None:
|
||||
"""Activate timer mode with a target temperature and duration."""
|
||||
if not self._attr_min_temp <= temperature <= self._attr_max_temp:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timer_temperature_out_of_range",
|
||||
translation_placeholders={
|
||||
"temperature": str(temperature),
|
||||
"min_temp": str(self._attr_min_temp),
|
||||
"max_temp": str(self._attr_max_temp),
|
||||
},
|
||||
)
|
||||
|
||||
duration_minutes, remainder = divmod(duration, timedelta(minutes=1))
|
||||
if remainder:
|
||||
duration_minutes += 1
|
||||
|
||||
try:
|
||||
await self.coordinator.client.activate_thermostat_timer(
|
||||
self.device_id, temperature, duration_minutes
|
||||
)
|
||||
except (WattsVisionError, ValueError, RuntimeError) as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="activate_timer_mode_error",
|
||||
) from err
|
||||
|
||||
_LOGGER.debug(
|
||||
"Successfully activated timer mode: %s%s for %d min on %s",
|
||||
temperature,
|
||||
self.temperature_unit,
|
||||
duration_minutes,
|
||||
self.device_id,
|
||||
)
|
||||
|
||||
self.coordinator.trigger_fast_polling()
|
||||
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
mode = HVAC_MODE_TO_THERMOSTAT[hvac_mode]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user