Compare commits

...

58 Commits

Author SHA1 Message Date
Erik d8e425e002 Improve test 2026-05-13 11:42:04 +02:00
Erik 6e03333da6 Add test 2026-05-13 11:05:53 +02:00
Erik 00b8e67639 Remove fixtures patching removed functionality 2026-05-13 10:51:35 +02:00
Erik 03a7590d20 Remove tests 2026-05-13 10:46:26 +02:00
Erik 5ffcd04ccb Merge remote-tracking branch 'upstream/dev' into remove_deps_support 2026-05-13 10:43:37 +02:00
Erik Montnemery 676df1d2b2 Fix cv.CONDITION_SCHEMA (#170395) 2026-05-12 11:39:43 +02:00
Artur Pragacz 36cc629faf Validate device info string fields in the registry (#170021) 2026-05-12 11:01:04 +02:00
Marc Mueller 99b1e7c229 Enable parallel type checking for mypy (#170381) 2026-05-12 10:34:47 +02:00
renovate[bot] cfdb00bf36 Update pyOpenSSL to 26.2.0 (#170371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-12 10:17:56 +02:00
puddly 9b8c81cba1 Bump serialx to 1.7.3 (#170368) 2026-05-12 07:52:05 +02:00
Petar Petrov 095cf07f43 Add battery state of charge to energy preferences (#169550)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-05-12 08:37:25 +03:00
Marc Mueller b275791a71 Update mypy to 2.1.0 (#170352) 2026-05-12 05:08:39 +02:00
Lukas e7dccd3ad3 Bump infrared-protocols to 5.1.0 (#170365) 2026-05-11 22:36:56 -04:00
Franck Nijhof adab0d6486 Clean up template engine after extension modularization (#170346) 2026-05-11 18:41:54 -04:00
Robert Svensson aad964889f Bump axis to v71 (#170347) 2026-05-11 23:10:20 +02:00
Jan-Philipp Benecke 9200658526 Enhance WebDAV metadata download with concurrency (#170223) 2026-05-11 21:33:22 +02:00
Christian Lackas 68f10249a5 Add target temperature sensor for ViCare RadiatorActuator devices (#170102) 2026-05-11 21:32:47 +02:00
Andreas Schneider b5ee78aeac Bump pyzbar to 0.1.9 (#170076) 2026-05-11 21:32:19 +02:00
Christian Lackas 86a967ee7b homematicip_cloud: fix HmIP-FLC lock state polarity (#170159) 2026-05-11 21:29:00 +02:00
theobld-ww eeca75b937 Watts: add timer mode service (#169846)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 21:18:13 +02:00
Franck Nijhof ce6b6601fa Set parallel updates for Ecowitt platforms (#170349) 2026-05-11 21:00:15 +02:00
Sören 4641c829ca Add config flow to Avea (#168070)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-11 18:42:24 +02:00
Joost Lekkerkerker 56fbd096e2 Cleanup Eurotronic number platform (#170337) 2026-05-11 18:30:58 +02:00
Richard Kroegel c071c08f86 Add number platform to eurotronic_cometblue (#168119)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 17:42:56 +02:00
TheJulianJES e47c152222 Add ZWaveNodeBaseEntity for Z-Wave node-level entities (#170124) 2026-05-11 17:34:30 +02:00
Martin Claesson 8232415fd5 Add Kiosker switch platform (#168858) 2026-05-11 16:31:14 +02:00
A. Gideonse dcc95328ec Complete exception translations for Indevolt (#170291)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 10:26:26 -04:00
Karl Beecken 85faab5d5d Bump teltasync to 0.3.0, fix discovery for older devices (#169660)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 15:21:09 +01:00
MoonDevLT bacb8a8fea Update discovery description wording (#170325) 2026-05-11 15:57:40 +02:00
Maciej Bieniek c9926915ff Fix Shelly media player availability (#170319) 2026-05-11 15:57:37 +02:00
Joshua Leaper 0772034d9d Add quality scale file to Ness Alarm (#163425)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-11 15:46:30 +02:00
r2xj 8cfdc52762 Only use SmartThings switch for light if it should (#166424)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-11 15:16:18 +02:00
Brett Adams 738b9936d9 Add quality scale to Tesla Fleet integration (#160475)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-11 15:10:21 +02:00
lucsansag b3bb5c9abc Google assistant temperature setting active thermostat mode (#166448)
Co-authored-by: Lucas Sanchez Sagrado <lucas.sansag@educa.jcyl.es>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-11 15:08:00 +02:00
Jan Bouwhuis 3149da12a4 Fix MQTT device discovery not using shared QoS and encoding options (#170195)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 14:53:32 +02:00
Tomeamis e2805e4489 Z-Wave.me: Allow updating entities (#167839) 2026-05-11 14:49:40 +02:00
noifen 14a8ef6e48 Allow setting hvac_mode in generic_thermostat.set_temperature (#168062)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-05-11 14:41:53 +02:00
Peter 015fc5809a Add countdown number for Tuya sfkzq single-valve timers (#170318) 2026-05-11 14:14:55 +02:00
Simone Chemelli 2e4f4040c7 Bump aiovodafone to 3.2.0 (#170322) 2026-05-11 14:14:41 +02:00
TomFilsell 095de73a53 Fix coordinator data mutation in YouTube diagnostics (#170300)
Co-authored-by: FIls0010 <a1867444@adelaide.edu.au>
2026-05-11 08:03:33 -04:00
Nikolai Rahimi 7dca14e78a Add Mitsubishi brand (#169924)
Co-authored-by: Nikolai Rahimi <nikolairahimi@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:02:10 -04:00
nayfield 0a974cbc7a Add cover support to control4 (#169417)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:37:49 +02:00
Øyvind Matheson Wergeland 2e37a0bba6 Fix nobo_hub NoboProfileSelector class-level mutable defaults (#170119) 2026-05-11 13:35:38 +02:00
HoffmanEl 7e2ec795d6 Add quality scale for airnow integration (#169709)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 13:28:52 +02:00
theobld-ww 7ba7700d5e Watts: add HVAC action + preset mode (#169546)
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-05-11 13:27:01 +02:00
Greg Haines 261ca2dd9a Add new CentriConnect component (#166933)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-11 13:23:50 +02:00
Jan Bouwhuis 284478f620 Add Message Expire Interval option to MQTT publish service (#169317)
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 13:01:06 +02:00
Paulus Schoutsen 62ac3f9834 Update rf-protocols to 3.0.0 (#170301)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:46:26 +02:00
Marcos A L M Macedo 3bf57ae9cd Add Tuya DLQ fixture (#169585) 2026-05-11 12:28:11 +02:00
Kamil Breguła ed0abfb238 Add more entities for Tuya camera (#169966)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-05-11 12:25:42 +02:00
Peter 0789eb0db6 Add tuya water timer data points (#170314) 2026-05-11 12:24:14 +02:00
Marc Mueller 980d43accc Add backoff to forbidden packages (#170242)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 12:14:37 +02:00
Stefan Agner 6d8b010245 Fix hassio.backup_partial AttributeError when folders are specified (#170312)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:10:37 +02:00
Jordan Harvey dc9eba372a Add player specific sensor to nintendo_parental_controls (#155786)
Co-authored-by: Joshua Peisach (ItzSwirlz) <itzswirlz2020@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-05-11 12:00:17 +02:00
Simone Chemelli 20827b66d9 Update IQS to platinum for UptimeRobot (#170260) 2026-05-11 11:26:30 +02:00
A. Gideonse a43ab34302 Bump indevolt api to 1.7.2 (#170310) 2026-05-11 10:37:48 +02:00
Craig Dean b14e863877 Bump renault-api to 0.5.8 (#170309) 2026-05-11 10:36:47 +02:00
Erik 65f073ca15 Remove support for installing Python dependencies in the config dir 2026-04-14 09:05:43 +02:00
258 changed files with 10854 additions and 1352 deletions
+2 -2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1 -15
View File
@@ -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,
+5
View File
@@ -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."
}
}
}
+33 -1
View File
@@ -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."""
+8
View File
@@ -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"
+142 -18
View File
@@ -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))
+9 -1
View File
@@ -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"
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==70"],
"requirements": ["axis==71"],
"ssdp": [
{
"manufacturer": "AXIS"
+3 -1
View File
@@ -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
+220
View File
@@ -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__)
+4
View File
@@ -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):
+36 -7
View File
@@ -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"]
}
+6 -1
View File
@@ -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
+6 -1
View File
@@ -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": {
+6 -1
View File
@@ -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"]
}
+1 -1
View File
@@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import KioskerConfigEntry, KioskerDataUpdateCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
_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": {
+14 -1
View File
@@ -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
+46 -6
View File
@@ -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)
+1
View File
@@ -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.
+2
View File
@@ -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"
},
+2 -2
View File
@@ -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.
+12 -1
View File
@@ -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,
),
),
}
+8
View File
@@ -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"
+23 -1
View File
@@ -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(
+11
View File
@@ -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"
},
+10
View File
@@ -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
+1 -1
View File
@@ -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"]
}
+15 -1
View File
@@ -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:
+102 -5
View File
@@ -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