Compare commits

..

47 Commits

Author SHA1 Message Date
jbouwh
79738cfa0d Allow to wait for MQTT subscription 2025-09-25 18:54:38 +00:00
Marc Mueller
9cc78680d6 Fix lg_thinq test RuntimeWarning (#152910) 2025-09-24 21:28:49 +03:00
Simone Chemelli
14d42e43bf Add dynamic devices management for Alexa Devices (#151975) 2025-09-24 19:00:35 +02:00
Simone Chemelli
ed5f5d4b33 Add dynamic devices management for Comelit SimpleHome (#152137) 2025-09-24 18:57:56 +02:00
Kinachi249
c3ba086fad Add new Cync by GE integration (#149848)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-09-24 18:43:50 +02:00
Abílio Costa
7b5314605c Revert "Rename function arguments in modbus (#152814)" (#152904) 2025-09-24 17:25:01 +01:00
Karsten Bade
3a806d6603 Add dc:title support for Sonos sharelinks (#152774)
Co-authored-by: Pete Sage <76050312+PeteRager@users.noreply.github.com>
2025-09-24 17:23:58 +01:00
starkillerOG
6dd33f900d Add support for Reolink chime connected to Home Hub (#151199) 2025-09-24 18:07:23 +02:00
Paul Bottein
2844bd474a Update frontend to 20250924.0 (#152901) 2025-09-24 18:05:13 +02:00
Artur Pragacz
d865fcf999 Do not include capabilities in extended analytics (#152900)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-09-24 11:58:44 -04:00
Bouwe Westerdijk
79a2fc5a01 Snapshot testing for Plugwise Select platform (#152827) 2025-09-24 17:51:04 +02:00
alorente
19d87abb8a Add Q-Adapt to Airzone integration (#151945) 2025-09-24 17:43:32 +02:00
Joris Pelgröm
c4de46a85b Add number platform to LetPot integration (#151092) 2025-09-24 17:41:36 +02:00
epenet
e79a434d9b Use DeviceCategory in Tuya remaining platforms (#152890) 2025-09-24 17:39:46 +02:00
Manu
9a801424c7 Fix deleting message filters in ntfy integration (#152783) 2025-09-24 17:38:40 +02:00
Paulus Schoutsen
5cb186980a Mark MQTT as service (#152899) 2025-09-24 11:35:23 -04:00
Ravaka Razafimanantsoa
1629ade97f Add Smart Meter B Route integration (#123446)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-24 17:31:30 +02:00
puddly
ccf0011ac2 Skip ignored discovery entries when showing migrate/setup config flow steps for ZHA and Hardware (#152895) 2025-09-24 17:31:04 +02:00
puddly
70077511a3 Unload ZHA integration before adapter migration (#152896) 2025-09-24 17:28:55 +02:00
Petar Petrov
dfbaf66021 Add progress step decorator for easier config flows (#152739)
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-09-24 18:18:42 +03:00
Richard Polzer
62cea48a58 Fix typo in ekeybionyx strings.json (#152889) 2025-09-24 15:46:22 +01:00
Michael Hansen
c493c7dd67 Bump intents and fix tests (#152893) 2025-09-24 09:24:42 -05:00
Maciej Bieniek
fdaceaddfd Add new virtual integration Neo (#152886) 2025-09-24 14:57:22 +02:00
epenet
a2f4073d54 Use DeviceCategory in Tuya more platforms (#152885) 2025-09-24 14:40:25 +02:00
epenet
2d01a99ec2 Bump renault-api to 0.4.1 (#152883) 2025-09-24 12:44:33 +01:00
epenet
311d4c4262 Use DeviceCategory in Tuya binary sensor (#152882) 2025-09-24 13:31:44 +02:00
Erik Montnemery
e14f5ba44d Fix misleading + unclear comment in homeassistant.const (#152878) 2025-09-24 13:22:32 +02:00
Artur Pragacz
9babc85517 Add analytics to core files (#152877) 2025-09-24 13:21:40 +02:00
Marc Mueller
332a3fad3c Fix mypy errors (#152879) 2025-09-24 13:09:32 +02:00
Michael
8782aa4f60 Hide asserts behind TYPE_CHECKING in Synology DSM (#152880) 2025-09-24 12:49:44 +02:00
jan iversen
475b84cc5f Remove codeowner. (#152869) 2025-09-24 12:43:22 +02:00
Artur Pragacz
0f904d418b Filter out integration types in extended analytics (#152874) 2025-09-24 12:32:30 +02:00
Artur Pragacz
4ea4eec2d8 Remove analytics platform in template (#152876) 2025-09-24 12:32:14 +02:00
Artur Pragacz
afefa16615 Remove analytics platform in automation (#152875) 2025-09-24 12:29:33 +02:00
Martin Hjelmare
1dccbee45c Remove hardware flow thread confirm step after install (#152868) 2025-09-24 11:28:10 +01:00
Simone Chemelli
711a56db2f Add dynamic devices management for UptimeRobot (#152139) 2025-09-24 10:56:56 +01:00
Joost Lekkerkerker
9d1c7dadff Make SmartThings AC preset modes translatable (#152833) 2025-09-24 10:55:28 +01:00
Richard Polzer
7d1953e387 Add Ekey Bionyx integration (#139132)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-24 11:54:27 +02:00
Martin Hjelmare
023ecf2a64 Patch async_setup_entry in hardware integration flow tests (#152871) 2025-09-24 11:49:01 +02:00
epenet
934db458a3 Simplify access to Tuya device manager in async_setup_entry (#152859) 2025-09-24 11:47:28 +02:00
epenet
0a6ae3b52a Add enum for Tuya device categories (#152858) 2025-09-24 11:46:33 +02:00
Patrick
bdd0b74d51 Enhance Synology DSM handling of external USB drives (#145943)
Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com>
2025-09-24 11:26:22 +02:00
Norbert Rittel
8837f2aca7 Capitalize "Auto Cycle Link" as feature name in smartthings (#152864) 2025-09-24 11:11:35 +02:00
Artur Pragacz
403cd2d8ef Filter out custom integrations in extended analytics (#152820) 2025-09-24 10:24:42 +02:00
Erik Montnemery
ddfc528d63 Fix apparent copy-paste error in tests of trigger helper (#152855) 2025-09-24 08:38:32 +02:00
Nick Kuiper
ddea2206c3 Add start charge session action for blue current integration. (#145446) 2025-09-24 08:11:33 +02:00
J. Nick Koston
32aacac550 Fix async_get_scanner return type for BleakScanner compatibility (#152840) 2025-09-23 23:14:08 -05:00
197 changed files with 7498 additions and 3663 deletions

View File

@@ -58,6 +58,7 @@ base_platforms: &base_platforms
# Extra components that trigger the full suite
components: &components
- homeassistant/components/alexa/**
- homeassistant/components/analytics/**
- homeassistant/components/application_credentials/**
- homeassistant/components/assist_pipeline/**
- homeassistant/components/auth/**

View File

@@ -443,6 +443,7 @@ homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roborock.*
homeassistant.components.roku.*
homeassistant.components.romy.*
homeassistant.components.route_b_smart_meter.*
homeassistant.components.rpi_power.*
homeassistant.components.rss_feed_template.*
homeassistant.components.russound_rio.*

8
CODEOWNERS generated
View File

@@ -316,6 +316,8 @@ build.json @home-assistant/supervisor
/tests/components/crownstone/ @Crownstone @RicArch97
/homeassistant/components/cups/ @fabaff
/tests/components/cups/ @fabaff
/homeassistant/components/cync/ @Kinachi249
/tests/components/cync/ @Kinachi249
/homeassistant/components/daikin/ @fredrike
/tests/components/daikin/ @fredrike
/homeassistant/components/date/ @home-assistant/core
@@ -410,6 +412,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/eheimdigital/ @autinerd
/tests/components/eheimdigital/ @autinerd
/homeassistant/components/ekeybionyx/ @richardpolzer
/tests/components/ekeybionyx/ @richardpolzer
/homeassistant/components/electrasmart/ @jafar-atili
/tests/components/electrasmart/ @jafar-atili
/homeassistant/components/electric_kiwi/ @mikey0000
@@ -972,8 +976,6 @@ build.json @home-assistant/supervisor
/tests/components/moat/ @bdraco
/homeassistant/components/mobile_app/ @home-assistant/core
/tests/components/mobile_app/ @home-assistant/core
/homeassistant/components/modbus/ @janiversen
/tests/components/modbus/ @janiversen
/homeassistant/components/modem_callerid/ @tkdrob
/tests/components/modem_callerid/ @tkdrob
/homeassistant/components/modern_forms/ @wonderslug
@@ -1332,6 +1334,8 @@ build.json @home-assistant/supervisor
/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous
/homeassistant/components/roon/ @pavoni
/tests/components/roon/ @pavoni
/homeassistant/components/route_b_smart_meter/ @SeraphicRav
/tests/components/route_b_smart_meter/ @SeraphicRav
/homeassistant/components/rpi_power/ @shenxn @swetoast
/tests/components/rpi_power/ @shenxn @swetoast
/homeassistant/components/rss_feed_template/ @home-assistant/core

View File

@@ -4,11 +4,9 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from bleak import BleakScanner
from homeassistant.components.bluetooth import async_get_scanner
from homeassistant.config_entries import ConfigEntry
@@ -45,7 +43,7 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
scanner=cast(BleakScanner, async_get_scanner(hass)),
scanner=async_get_scanner(hass),
)
@property

View File

@@ -6,17 +6,19 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Final
from aioairzone.common import GrilleAngle, OperationMode, SleepTimeout
from aioairzone.common import GrilleAngle, OperationMode, QAdapt, SleepTimeout
from aioairzone.const import (
API_COLD_ANGLE,
API_HEAT_ANGLE,
API_MODE,
API_Q_ADAPT,
API_SLEEP,
AZD_COLD_ANGLE,
AZD_HEAT_ANGLE,
AZD_MASTER,
AZD_MODE,
AZD_MODES,
AZD_Q_ADAPT,
AZD_SLEEP,
AZD_ZONES,
)
@@ -65,6 +67,14 @@ SLEEP_DICT: Final[dict[str, int]] = {
"90m": SleepTimeout.SLEEP_90,
}
Q_ADAPT_DICT: Final[dict[str, int]] = {
"standard": QAdapt.STANDARD,
"power": QAdapt.POWER,
"silence": QAdapt.SILENCE,
"minimum": QAdapt.MINIMUM,
"maximum": QAdapt.MAXIMUM,
}
def main_zone_options(
zone_data: dict[str, Any],
@@ -83,6 +93,14 @@ MAIN_ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
options_fn=main_zone_options,
translation_key="modes",
),
AirzoneSelectDescription(
api_param=API_Q_ADAPT,
entity_category=EntityCategory.CONFIG,
key=AZD_Q_ADAPT,
options=list(Q_ADAPT_DICT),
options_dict=Q_ADAPT_DICT,
translation_key="q_adapt",
),
)

View File

@@ -63,6 +63,16 @@
"stop": "Stop"
}
},
"q_adapt": {
"name": "Q-Adapt",
"state": {
"standard": "Standard",
"power": "Power",
"silence": "Silence",
"minimum": "Minimum",
"maximum": "Maximum"
}
},
"sleep_times": {
"name": "Sleep",
"state": {

View File

@@ -94,12 +94,24 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
)
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in new_devices
if sensor_desc.is_supported(
coordinator.data[serial_num], sensor_desc.key
)
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):

View File

@@ -57,13 +57,23 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in NOTIFY
for serial_num in coordinator.data
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
and sensor_desc.is_supported(coordinator.data[serial_num])
)
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AmazonNotifyEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in NOTIFY
for serial_num in new_devices
if sensor_desc.subkey in coordinator.data[serial_num].capabilities
and sensor_desc.is_supported(coordinator.data[serial_num])
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonNotifyEntity(AmazonEntity, NotifyEntity):

View File

@@ -53,7 +53,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done

View File

@@ -62,12 +62,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in SENSORS
for serial_num in coordinator.data
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
)
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AmazonSensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in SENSORS
for serial_num in new_devices
if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonSensorEntity(AmazonEntity, SensorEntity):

View File

@@ -48,12 +48,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in coordinator.data
if switch_desc.subkey in coordinator.data[serial_num].capabilities
)
known_devices: set[str] = set()
def _check_device() -> None:
current_devices = set(coordinator.data)
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in new_devices
if switch_desc.subkey in coordinator.data[serial_num].capabilities
)
_check_device()
entry.async_on_unload(coordinator.async_add_listener(_check_device))
class AmazonSwitchEntity(AmazonEntity, SwitchEntity):

View File

@@ -39,7 +39,7 @@ from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.helpers.typing import UNDEFINED
from homeassistant.loader import (
Integration,
IntegrationNotFound,
@@ -142,7 +142,6 @@ class EntityAnalyticsModifications:
"""
remove: bool = False
capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED
class AnalyticsPlatformProtocol(Protocol):
@@ -506,7 +505,7 @@ DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
async def async_devices_payload(hass: HomeAssistant) -> dict:
"""Return detailed information about entities and devices."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
@@ -538,6 +537,23 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
integration_input[1].append(entity_entry.entity_id)
integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, integration_inputs.keys())
).items()
if isinstance(integration, Integration)
}
# Filter out custom integrations and integrations that are not device or hub type
integration_inputs = {
domain: integration_info
for domain, integration_info in integration_inputs.items()
if (integration := integrations.get(domain)) is not None
and integration.is_built_in
and integration.integration_type in ("device", "hub")
}
# Call integrations that implement the analytics platform
for integration_domain, integration_input in integration_inputs.items():
if (
@@ -660,18 +676,14 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
# we should replace it with the original value in the future.
# It is also not present, if entity is not in the state machine,
# which can happen for disabled entities.
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
if entity_state is not None
else None,
"capabilities": entity_config.capabilities
if entity_config.capabilities is not UNDEFINED
else entity_entry.capabilities,
"assumed_state": (
entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
if entity_state is not None
else None
),
"domain": entity_entry.domain,
"entity_category": entity_entry.entity_category,
"has_entity_name": entity_entry.has_entity_name,
"modified_by_integration": ["capabilities"]
if entity_config.capabilities is not UNDEFINED
else None,
"original_device_class": entity_entry.original_device_class,
# LIMITATION: `unit_of_measurement` can be overridden by users;
# we should replace it with the original value in the future.
@@ -688,23 +700,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
else:
entities_info.append(entity_info)
integrations = {
domain: integration
for domain, integration in (
await async_get_integrations(hass, integrations_info.keys())
).items()
if isinstance(integration, Integration)
}
for domain, integration_info in integrations_info.items():
if integration := integrations.get(domain):
integration_info["is_custom_integration"] = not integration.is_built_in
# Include version for custom integrations
if not integration.is_built_in and integration.version:
integration_info["custom_integration_version"] = str(
integration.version
)
return {
"version": "home-assistant:1",
"home_assistant": HA_VERSION,

View File

@@ -1,24 +0,0 @@
"""Analytics platform."""
from homeassistant.components.analytics import (
AnalyticsInput,
AnalyticsModifications,
EntityAnalyticsModifications,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
ent_reg = er.async_get(hass)
entities: dict[str, EntityAnalyticsModifications] = {}
for entity_id in analytics_input.entity_ids:
entity_entry = ent_reg.entities[entity_id]
if entity_entry.capabilities is not None:
entities[entity_id] = EntityAnalyticsModifications(capabilities=None)
return AnalyticsModifications(entities=entities)

View File

@@ -13,20 +13,30 @@ from bluecurrent_api.exceptions import (
RequestLimitReached,
WebsocketError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_API_TOKEN, CONF_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
ServiceValidationError,
)
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from .const import (
BCU_APP,
CHARGEPOINT_SETTINGS,
CHARGEPOINT_STATUS,
CHARGING_CARD_ID,
DOMAIN,
EVSE_ID,
LOGGER,
PLUG_AND_CHARGE,
SERVICE_START_CHARGE_SESSION,
VALUE,
)
@@ -34,6 +44,7 @@ type BlueCurrentConfigEntry = ConfigEntry[Connector]
PLATFORMS = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
CHARGE_POINTS = "CHARGE_POINTS"
CHARGE_CARDS = "CHARGE_CARDS"
DATA = "data"
DELAY = 5
@@ -41,6 +52,16 @@ GRID = "GRID"
OBJECT = "object"
VALUE_TYPES = [CHARGEPOINT_STATUS, CHARGEPOINT_SETTINGS]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_START_CHARGE_SESSION_SCHEMA = vol.Schema(
{
vol.Required(CONF_DEVICE_ID): cv.string,
# When no charging card is provided, use no charging card (BCU_APP = no charging card).
vol.Optional(CHARGING_CARD_ID, default=BCU_APP): cv.string,
}
)
async def async_setup_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
@@ -67,6 +88,66 @@ async def async_setup_entry(
return True
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Blue Current."""
async def start_charge_session(service_call: ServiceCall) -> None:
"""Start a charge session with the provided device and charge card ID."""
# When no charge card is provided, use the default charge card set in the config flow.
charging_card_id = service_call.data[CHARGING_CARD_ID]
device_id = service_call.data[CONF_DEVICE_ID]
# Get the device based on the given device ID.
device = dr.async_get(hass).devices.get(device_id)
if device is None:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="invalid_device_id"
)
blue_current_config_entry: ConfigEntry | None = None
for config_entry_id in device.config_entries:
config_entry = hass.config_entries.async_get_entry(config_entry_id)
if not config_entry or config_entry.domain != DOMAIN:
# Not the blue_current config entry.
continue
if config_entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
blue_current_config_entry = config_entry
break
if not blue_current_config_entry:
# The device is not connected to a valid blue_current config entry.
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="no_config_entry"
)
connector = blue_current_config_entry.runtime_data
# Get the evse_id from the identifier of the device.
evse_id = next(
identifier[1]
for identifier in device.identifiers
if identifier[0] == DOMAIN
)
await connector.client.start_session(evse_id, charging_card_id)
hass.services.async_register(
DOMAIN,
SERVICE_START_CHARGE_SESSION,
start_charge_session,
SERVICE_START_CHARGE_SESSION_SCHEMA,
)
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: BlueCurrentConfigEntry
) -> bool:
@@ -87,6 +168,7 @@ class Connector:
self.client = client
self.charge_points: dict[str, dict] = {}
self.grid: dict[str, Any] = {}
self.charge_cards: dict[str, dict[str, Any]] = {}
async def on_data(self, message: dict) -> None:
"""Handle received data."""

View File

@@ -8,6 +8,12 @@ LOGGER = logging.getLogger(__package__)
EVSE_ID = "evse_id"
MODEL_TYPE = "model_type"
CARD = "card"
UID = "uid"
BCU_APP = "BCU-APP"
WITHOUT_CHARGING_CARD = "without_charging_card"
CHARGING_CARD_ID = "charging_card_id"
SERVICE_START_CHARGE_SESSION = "start_charge_session"
PLUG_AND_CHARGE = "plug_and_charge"
VALUE = "value"
PERMISSION = "permission"

View File

@@ -42,5 +42,10 @@
"default": "mdi:lock"
}
}
},
"services": {
"start_charge_session": {
"service": "mdi:play"
}
}
}

View File

@@ -0,0 +1,12 @@
start_charge_session:
fields:
device_id:
selector:
device:
integration: blue_current
required: true
charging_card_id:
selector:
text:
required: false

View File

@@ -22,6 +22,16 @@
"wrong_account": "Wrong account: Please authenticate with the API token for {email}."
}
},
"options": {
"step": {
"init": {
"data": {
"card": "Card"
},
"description": "Select the default charging card you want to use"
}
}
},
"entity": {
"sensor": {
"activity": {
@@ -136,5 +146,39 @@
"name": "Block charge point"
}
}
},
"selector": {
"select_charging_card": {
"options": {
"without_charging_card": "Without charging card"
}
}
},
"services": {
"start_charge_session": {
"name": "Start charge session",
"description": "Starts a new charge session on a specified charge point.",
"fields": {
"charging_card_id": {
"name": "Charging card ID",
"description": "Optional charging card ID that will be used to start a charge session. When not provided, no charging card will be used."
},
"device_id": {
"name": "Device ID",
"description": "The ID of the Blue Current charge point."
}
}
}
},
"exceptions": {
"invalid_device_id": {
"message": "Invalid device ID given."
},
"config_entry_not_loaded": {
"message": "Config entry not loaded."
},
"no_config_entry": {
"message": "Device has not a valid blue_current config entry."
}
}
}

View File

@@ -10,6 +10,7 @@ from asyncio import Future
from collections.abc import Callable, Iterable
from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
from habluetooth import (
BaseHaScanner,
BluetoothScannerDevice,
@@ -38,13 +39,16 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
@hass_callback
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
"""Return a HaBleakScannerWrapper.
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
"""Return a HaBleakScannerWrapper cast to BleakScanner.
This is a wrapper around our BleakScanner singleton that allows
multiple integrations to share the same BleakScanner.
The wrapper is cast to BleakScanner for type compatibility with
libraries expecting a BleakScanner instance.
"""
return HaBleakScannerWrapper()
return cast(BleakScanner, HaBleakScannerWrapper())
@hass_callback

View File

@@ -29,10 +29,23 @@ async def async_setup_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data["alarm_zones"].values()
)
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data["alarm_zones"])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoBinarySensorEntity(
coordinator, device, config_entry.entry_id
)
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitVedoBinarySensorEntity(

View File

@@ -29,10 +29,21 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[COVER].values()
)
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data[COVER])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitCoverEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[COVER].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):

View File

@@ -27,10 +27,21 @@ async def async_setup_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
)
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data[LIGHT])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitLightEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[LIGHT].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):

View File

@@ -57,9 +57,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: missing implementation
dynamic-devices: done
entity-category:
status: exempt
comment: no config or diagnostic entities

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Final, cast
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.api import ComelitSerialBridgeObject, ComelitVedoZoneObject
from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from homeassistant.components.sensor import (
@@ -65,15 +65,24 @@ async def async_setup_bridge_entry(
coordinator = cast(ComelitSerialBridge, config_entry.runtime_data)
entities: list[ComelitBridgeSensorEntity] = []
for device in coordinator.data[OTHER].values():
entities.extend(
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data[OTHER])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitBridgeSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_BRIDGE_TYPES
for device in coordinator.data[OTHER].values()
if device.index in new_devices
)
for sensor_desc in SENSOR_BRIDGE_TYPES
)
async_add_entities(entities)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
async def async_setup_vedo_entry(
@@ -85,15 +94,24 @@ async def async_setup_vedo_entry(
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
entities: list[ComelitVedoSensorEntity] = []
for device in coordinator.data["alarm_zones"].values():
entities.extend(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
known_devices: set[int] = set()
def _check_device() -> None:
current_devices = set(coordinator.data["alarm_zones"])
new_devices = current_devices - known_devices
if new_devices:
known_devices.update(new_devices)
async_add_entities(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
)
for sensor_desc in SENSOR_VEDO_TYPES
for device in coordinator.data["alarm_zones"].values()
if device.index in new_devices
)
for sensor_desc in SENSOR_VEDO_TYPES
)
async_add_entities(entities)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):

View File

@@ -39,6 +39,25 @@ async def async_setup_entry(
)
async_add_entities(entities)
known_devices: dict[str, set[int]] = {
dev_type: set() for dev_type in (IRRIGATION, OTHER)
}
def _check_device() -> None:
for dev_type in (IRRIGATION, OTHER):
current_devices = set(coordinator.data[dev_type])
new_devices = current_devices - known_devices[dev_type]
if new_devices:
known_devices[dev_type].update(new_devices)
async_add_entities(
ComelitSwitchEntity(coordinator, device, config_entry.entry_id)
for device in coordinator.data[dev_type].values()
if device.index in new_devices
)
_check_device()
config_entry.async_on_unload(coordinator.async_add_listener(_check_device))
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"]
"requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"]
}

View File

@@ -0,0 +1,58 @@
"""The Cync integration."""
from __future__ import annotations
from pycync import Auth, Cync, User
from pycync.exceptions import AuthFailedError, CyncError
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_AUTHORIZE_STRING,
CONF_EXPIRES_AT,
CONF_REFRESH_TOKEN,
CONF_USER_ID,
)
from .coordinator import CyncConfigEntry, CyncCoordinator
_PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
"""Set up Cync from a config entry."""
user_info = User(
entry.data[CONF_ACCESS_TOKEN],
entry.data[CONF_REFRESH_TOKEN],
entry.data[CONF_AUTHORIZE_STRING],
entry.data[CONF_USER_ID],
expires_at=entry.data[CONF_EXPIRES_AT],
)
cync_auth = Auth(async_get_clientsession(hass), user=user_info)
try:
cync = await Cync.create(cync_auth)
except AuthFailedError as ex:
raise ConfigEntryAuthFailed("User token invalid") from ex
except CyncError as ex:
raise ConfigEntryNotReady("Unable to connect to Cync") from ex
devices_coordinator = CyncCoordinator(hass, entry, cync)
cync.set_update_callback(devices_coordinator.on_data_update)
await devices_coordinator.async_config_entry_first_refresh()
entry.runtime_data = devices_coordinator
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: CyncConfigEntry) -> bool:
"""Unload a config entry."""
cync = entry.runtime_data.cync
await cync.shut_down()
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,118 @@
"""Config flow for the Cync integration."""
from __future__ import annotations
import logging
from typing import Any
from pycync import Auth
from pycync.exceptions import AuthFailedError, CyncError, TwoFactorRequiredError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_AUTHORIZE_STRING,
CONF_EXPIRES_AT,
CONF_REFRESH_TOKEN,
CONF_TWO_FACTOR_CODE,
CONF_USER_ID,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
)
STEP_TWO_FACTOR_SCHEMA = vol.Schema({vol.Required(CONF_TWO_FACTOR_CODE): str})
class CyncConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Cync."""
VERSION = 1
cync_auth: Auth
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Attempt login with user credentials."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
self.cync_auth = Auth(
async_get_clientsession(self.hass),
username=user_input[CONF_EMAIL],
password=user_input[CONF_PASSWORD],
)
try:
await self.cync_auth.login()
except AuthFailedError:
errors["base"] = "invalid_auth"
except TwoFactorRequiredError:
return await self.async_step_two_factor()
except CyncError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self._create_config_entry(self.cync_auth.username)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_two_factor(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Attempt login with the two factor auth code sent to the user."""
errors: dict[str, str] = {}
if user_input is None:
return self.async_show_form(
step_id="two_factor", data_schema=STEP_TWO_FACTOR_SCHEMA, errors=errors
)
try:
await self.cync_auth.login(user_input[CONF_TWO_FACTOR_CODE])
except AuthFailedError:
errors["base"] = "invalid_auth"
except CyncError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self._create_config_entry(self.cync_auth.username)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def _create_config_entry(self, user_email: str) -> ConfigFlowResult:
"""Create the Cync config entry using input user data."""
cync_user = self.cync_auth.user
await self.async_set_unique_id(str(cync_user.user_id))
self._abort_if_unique_id_configured()
config = {
CONF_USER_ID: cync_user.user_id,
CONF_AUTHORIZE_STRING: cync_user.authorize,
CONF_EXPIRES_AT: cync_user.expires_at,
CONF_ACCESS_TOKEN: cync_user.access_token,
CONF_REFRESH_TOKEN: cync_user.refresh_token,
}
return self.async_create_entry(title=user_email, data=config)

View File

@@ -0,0 +1,9 @@
"""Constants for the Cync integration."""
DOMAIN = "cync"
CONF_TWO_FACTOR_CODE = "two_factor_code"
CONF_USER_ID = "user_id"
CONF_AUTHORIZE_STRING = "authorize_string"
CONF_EXPIRES_AT = "expires_at"
CONF_REFRESH_TOKEN = "refresh_token"

View File

@@ -0,0 +1,87 @@
"""Coordinator to handle keeping device states up to date."""
from __future__ import annotations
from datetime import timedelta
import logging
import time
from pycync import Cync, CyncDevice, User
from pycync.exceptions import AuthFailedError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_EXPIRES_AT, CONF_REFRESH_TOKEN
_LOGGER = logging.getLogger(__name__)
type CyncConfigEntry = ConfigEntry[CyncCoordinator]
class CyncCoordinator(DataUpdateCoordinator[dict[int, CyncDevice]]):
"""Coordinator to handle updating Cync device states."""
config_entry: CyncConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: CyncConfigEntry, cync: Cync
) -> None:
"""Initialize the Cync coordinator."""
super().__init__(
hass,
_LOGGER,
name="Cync Data Coordinator",
config_entry=config_entry,
update_interval=timedelta(seconds=30),
always_update=True,
)
self.cync = cync
async def on_data_update(self, data: dict[int, CyncDevice]) -> None:
"""Update registered devices with new data."""
merged_data = self.data | data if self.data else data
self.async_set_updated_data(merged_data)
async def _async_setup(self) -> None:
"""Set up the coordinator with initial device states."""
logged_in_user = self.cync.get_logged_in_user()
if logged_in_user.access_token != self.config_entry.data[CONF_ACCESS_TOKEN]:
await self._update_config_cync_credentials(logged_in_user)
async def _async_update_data(self) -> dict[int, CyncDevice]:
"""First, refresh the user's auth token if it is set to expire in less than one hour.
Then, fetch all current device states.
"""
logged_in_user = self.cync.get_logged_in_user()
if logged_in_user.expires_at - time.time() < 3600:
await self._async_refresh_cync_credentials()
self.cync.update_device_states()
current_device_states = self.cync.get_devices()
return {device.device_id: device for device in current_device_states}
async def _async_refresh_cync_credentials(self) -> None:
"""Attempt to refresh the Cync user's authentication token."""
try:
refreshed_user = await self.cync.refresh_credentials()
except AuthFailedError as ex:
raise ConfigEntryAuthFailed("Unable to refresh user token") from ex
else:
await self._update_config_cync_credentials(refreshed_user)
async def _update_config_cync_credentials(self, user_info: User) -> None:
"""Update the config entry with current user info."""
new_data = {**self.config_entry.data}
new_data[CONF_ACCESS_TOKEN] = user_info.access_token
new_data[CONF_REFRESH_TOKEN] = user_info.refresh_token
new_data[CONF_EXPIRES_AT] = user_info.expires_at
self.hass.config_entries.async_update_entry(self.config_entry, data=new_data)

View File

@@ -0,0 +1,45 @@
"""Setup for a generic entity type for the Cync integration."""
from pycync.devices import CyncDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import CyncCoordinator
class CyncBaseEntity(CoordinatorEntity[CyncCoordinator]):
"""Generic base entity for Cync devices."""
_attr_has_entity_name = True
def __init__(
self,
device: CyncDevice,
coordinator: CyncCoordinator,
room_name: str | None = None,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
super().__init__(coordinator)
self._cync_device_id = device.device_id
self._attr_unique_id = device.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.unique_id)},
manufacturer="GE Lighting",
name=device.name,
suggested_area=room_name,
)
@property
def available(self) -> bool:
"""Determines whether this device is currently available."""
return (
super().available
and self.coordinator.data is not None
and self._cync_device_id in self.coordinator.data
and self.coordinator.data[self._cync_device_id].is_online
)

View File

@@ -0,0 +1,180 @@
"""Support for Cync light entities."""
from typing import Any
from pycync import CyncLight
from pycync.devices.capabilities import CyncCapability
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import value_to_brightness
from homeassistant.util.scaling import scale_ranged_value_to_int_range
from .coordinator import CyncConfigEntry, CyncCoordinator
from .entity import CyncBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: CyncConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cync lights from a config entry."""
coordinator = entry.runtime_data
cync = coordinator.cync
entities_to_add = []
for home in cync.get_homes():
for room in home.rooms:
room_lights = [
CyncLightEntity(device, coordinator, room.name)
for device in room.devices
if isinstance(device, CyncLight)
]
entities_to_add.extend(room_lights)
group_lights = [
CyncLightEntity(device, coordinator, room.name)
for group in room.groups
for device in group.devices
if isinstance(device, CyncLight)
]
entities_to_add.extend(group_lights)
async_add_entities(entities_to_add)
class CyncLightEntity(CyncBaseEntity, LightEntity):
"""Representation of a Cync light."""
_attr_color_mode = ColorMode.ONOFF
_attr_min_color_temp_kelvin = 2000
_attr_max_color_temp_kelvin = 7000
_attr_translation_key = "light"
_attr_name = None
BRIGHTNESS_SCALE = (0, 100)
def __init__(
self,
device: CyncLight,
coordinator: CyncCoordinator,
room_name: str | None = None,
) -> None:
"""Set up base attributes."""
super().__init__(device, coordinator, room_name)
supported_color_modes = {ColorMode.ONOFF}
if device.supports_capability(CyncCapability.CCT_COLOR):
supported_color_modes.add(ColorMode.COLOR_TEMP)
if device.supports_capability(CyncCapability.DIMMING):
supported_color_modes.add(ColorMode.BRIGHTNESS)
if device.supports_capability(CyncCapability.RGB_COLOR):
supported_color_modes.add(ColorMode.RGB)
self._attr_supported_color_modes = filter_supported_color_modes(
supported_color_modes
)
@property
def is_on(self) -> bool | None:
"""Return True if the light is on."""
return self._device.is_on
@property
def brightness(self) -> int:
"""Provide the light's current brightness."""
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
@property
def color_temp_kelvin(self) -> int:
"""Return color temperature in kelvin."""
return scale_ranged_value_to_int_range(
(1, 100),
(self.min_color_temp_kelvin, self.max_color_temp_kelvin),
self._device.color_temp,
)
@property
def rgb_color(self) -> tuple[int, int, int]:
"""Provide the light's current color in RGB format."""
return self._device.rgb
@property
def color_mode(self) -> str | None:
"""Return the active color mode."""
if (
self._device.supports_capability(CyncCapability.CCT_COLOR)
and self._device.color_mode > 0
and self._device.color_mode <= 100
):
return ColorMode.COLOR_TEMP
if (
self._device.supports_capability(CyncCapability.RGB_COLOR)
and self._device.color_mode == 254
):
return ColorMode.RGB
if self._device.supports_capability(CyncCapability.DIMMING):
return ColorMode.BRIGHTNESS
return ColorMode.ONOFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Process an action on the light."""
if not kwargs:
await self._device.turn_on()
elif kwargs.get(ATTR_COLOR_TEMP_KELVIN) is not None:
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
converted_color_temp = self._normalize_color_temp(color_temp)
await self._device.set_color_temp(converted_color_temp)
elif kwargs.get(ATTR_RGB_COLOR) is not None:
rgb = kwargs.get(ATTR_RGB_COLOR)
await self._device.set_rgb(rgb)
elif kwargs.get(ATTR_BRIGHTNESS) is not None:
brightness = kwargs.get(ATTR_BRIGHTNESS)
converted_brightness = self._normalize_brightness(brightness)
await self._device.set_brightness(converted_brightness)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
await self._device.turn_off()
def _normalize_brightness(self, brightness: float | None) -> int | None:
"""Return calculated brightness value scaled between 0-100."""
if brightness is not None:
return int((brightness / 255) * 100)
return None
def _normalize_color_temp(self, color_temp_kelvin: float | None) -> int | None:
"""Return calculated color temp value scaled between 1-100."""
if color_temp_kelvin is not None:
kelvin_range = self.max_color_temp_kelvin - self.min_color_temp_kelvin
scaled_kelvin = int(
((color_temp_kelvin - self.min_color_temp_kelvin) / kelvin_range) * 100
)
if scaled_kelvin == 0:
scaled_kelvin += 1
return scaled_kelvin
return None
@property
def _device(self) -> CyncLight:
"""Fetch the reference to the backing Cync light for this device."""
return self.coordinator.data[self._cync_device_id]

View File

@@ -0,0 +1,11 @@
{
"domain": "cync",
"name": "Cync",
"codeowners": ["@Kinachi249"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cync",
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.0"]
}

View File

@@ -0,0 +1,69 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional 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 additional 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 additional actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,32 @@
{
"config": {
"step": {
"user": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "Your Cync account's email address",
"password": "Your Cync account's password"
}
},
"two_factor": {
"data": {
"two_factor_code": "Two-factor code"
},
"data_description": {
"two_factor_code": "The two-factor code sent to your Cync account's email"
}
}
},
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
}
}

View File

@@ -0,0 +1,24 @@
"""The Ekey Bionyx integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS: list[Platform] = [Platform.EVENT]
type EkeyBionyxConfigEntry = ConfigEntry
async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
"""Set up the Ekey Bionyx config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,14 @@
"""application_credentials platform the Ekey Bionyx integration."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)

View File

@@ -0,0 +1,271 @@
"""Config flow for ekey bionyx."""
import asyncio
import json
import logging
import re
import secrets
from typing import Any, NotRequired, TypedDict
import aiohttp
import ekey_bionyxpy
import voluptuous as vol
from homeassistant.components.webhook import (
async_generate_id as webhook_generate_id,
async_generate_path as webhook_generate_path,
)
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.network import get_url
from homeassistant.helpers.selector import SelectOptionDict, SelectSelector
from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE
# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot
VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$")
class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth):
"""ekey bionyx authentication before a ConfigEntry exists.
This implementation directly provides the token without supporting refresh.
"""
def __init__(
self,
websession: aiohttp.ClientSession,
token: dict[str, Any],
) -> None:
"""Initialize ConfigFlowEkeyApi."""
super().__init__(websession, API_URL)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the token for the Ekey API."""
return self._token["access_token"]
class EkeyFlowData(TypedDict):
"""Type for Flow Data."""
api: NotRequired[ekey_bionyxpy.BionyxAPI]
system: NotRequired[ekey_bionyxpy.System]
systems: NotRequired[list[ekey_bionyxpy.System]]
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle ekey bionyx OAuth2 authentication."""
DOMAIN = DOMAIN
check_deletion_task: asyncio.Task[None] | None = None
def __init__(self) -> None:
"""Initialize OAuth2FlowHandler."""
super().__init__()
self._data: EkeyFlowData = {}
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": SCOPE}
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Start the user facing flow by initializing the API and getting the systems."""
client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN])
ap = ekey_bionyxpy.BionyxAPI(client)
self._data["api"] = ap
try:
system_res = await ap.get_systems()
except aiohttp.ClientResponseError:
return self.async_abort(
reason="cannot_connect",
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
)
system = [s for s in system_res if s.own_system]
if len(system) == 0:
return self.async_abort(reason="no_own_systems")
self._data["systems"] = system
if len(system) == 1:
# skipping choose_system since there is only one
self._data["system"] = system[0]
return await self.async_step_check_system(user_input=None)
return await self.async_step_choose_system(user_input=None)
async def async_step_choose_system(
self, user_input: dict[str, Any] | None
) -> ConfigFlowResult:
"""Dialog to choose System if multiple systems are present."""
if user_input is None:
options: list[SelectOptionDict] = [
{"value": s.system_id, "label": s.system_name}
for s in self._data["systems"]
]
data_schema = {vol.Required("system"): SelectSelector({"options": options})}
return self.async_show_form(
step_id="choose_system",
data_schema=vol.Schema(data_schema),
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
)
self._data["system"] = [
s for s in self._data["systems"] if s.system_id == user_input["system"]
][0]
return await self.async_step_check_system(user_input=None)
async def async_step_check_system(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Check if system has open webhooks."""
system = self._data["system"]
await self.async_set_unique_id(system.system_id)
self._abort_if_unique_id_configured()
if (
system.function_webhook_quotas["free"] == 0
and system.function_webhook_quotas["used"] == 0
):
return self.async_abort(
reason="no_available_webhooks",
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
)
if system.function_webhook_quotas["used"] > 0:
return await self.async_step_delete_webhooks()
return await self.async_step_webhooks(user_input=None)
async def async_step_webhooks(
self, user_input: dict[str, Any] | None
) -> ConfigFlowResult:
"""Dialog to setup webhooks."""
system = self._data["system"]
errors: dict[str, str] | None = None
if user_input is not None:
errors = {}
for key, webhook_name in user_input.items():
if key == CONF_URL:
continue
if not re.match(VALID_NAME_PATTERN, webhook_name):
errors.update({key: "invalid_name"})
try:
cv.url(user_input[CONF_URL])
except vol.Invalid:
errors[CONF_URL] = "invalid_url"
if set(user_input) == {CONF_URL}:
errors["base"] = "no_webhooks_provided"
if not errors:
webhook_data = [
{
"auth": secrets.token_hex(32),
"name": webhook_name,
"webhook_id": webhook_generate_id(),
}
for key, webhook_name in user_input.items()
if key != CONF_URL
]
for webhook in webhook_data:
wh_def: ekey_bionyxpy.WebhookData = {
"integrationName": "Home Assistant",
"functionName": webhook["name"],
"locationName": "Home Assistant",
"definition": {
"url": user_input[CONF_URL]
+ webhook_generate_path(webhook["webhook_id"]),
"authentication": {"apiAuthenticationType": "None"},
"securityLevel": "AllowHttp",
"method": "Post",
"body": {
"contentType": "application/json",
"content": json.dumps({"auth": webhook["auth"]}),
},
},
}
webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id
return self.async_create_entry(
title=self._data["system"].system_name,
data={"webhooks": webhook_data},
)
data_schema: dict[Any, Any] = {
vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50))
for i in range(self._data["system"].function_webhook_quotas["free"])
}
data_schema[vol.Required(CONF_URL)] = str
return self.async_show_form(
step_id="webhooks",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(data_schema),
{
CONF_URL: get_url(
self.hass,
allow_ip=True,
prefer_external=False,
)
}
| (user_input or {}),
),
errors=errors,
description_placeholders={
"webhooks_available": str(
self._data["system"].function_webhook_quotas["free"]
),
"ekeybionyx": INTEGRATION_NAME,
},
)
async def async_step_delete_webhooks(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Form to delete Webhooks."""
if user_input is None:
return self.async_show_form(step_id="delete_webhooks")
for webhook in await self._data["system"].get_webhooks():
await webhook.delete()
return await self.async_step_wait_for_deletion(user_input=None)
async def async_step_wait_for_deletion(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Wait for webhooks to be deleted in another flow."""
uncompleted_task: asyncio.Task[None] | None = None
if not self.check_deletion_task:
self.check_deletion_task = self.hass.async_create_task(
self.async_check_deletion_status()
)
if not self.check_deletion_task.done():
progress_action = "check_deletion_status"
uncompleted_task = self.check_deletion_task
if uncompleted_task:
return self.async_show_progress(
step_id="wait_for_deletion",
description_placeholders={"ekeybionyx": INTEGRATION_NAME},
progress_action=progress_action,
progress_task=uncompleted_task,
)
self.check_deletion_task = None
return self.async_show_progress_done(next_step_id="webhooks")
async def async_check_deletion_status(self) -> None:
"""Check if webhooks have been deleted."""
while True:
self._data["systems"] = await self._data["api"].get_systems()
self._data["system"] = [
s
for s in self._data["systems"]
if s.system_id == self._data["system"].system_id
][0]
if self._data["system"].function_webhook_quotas["used"] == 0:
break
await asyncio.sleep(5)

View File

@@ -0,0 +1,13 @@
"""Constants for the Ekey Bionyx integration."""
import logging
DOMAIN = "ekeybionyx"
INTEGRATION_NAME = "ekey bionyx"
LOGGER = logging.getLogger(__package__)
OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize"
OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token"
API_URL = "https://api.bionyx.io/3rd-party/api"
SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access"

View File

@@ -0,0 +1,70 @@
"""Event platform for ekey bionyx integration."""
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EkeyBionyxConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: EkeyBionyxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ekey event."""
async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"])
class EkeyEvent(EventEntity):
"""Ekey Event."""
_attr_device_class = EventDeviceClass.BUTTON
_attr_event_types = ["event happened"]
def __init__(
self,
data: dict[str, str],
) -> None:
"""Initialise a Ekey event entity."""
self._attr_name = data["name"]
self._attr_unique_id = data["ekey_id"]
self._webhook_id = data["webhook_id"]
self._auth = data["auth"]
@callback
def _async_handle_event(self) -> None:
"""Handle the webhook event."""
self._trigger_event("event happened")
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Register callbacks with your device API/library."""
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
if (await request.json())["auth"] == self._auth:
self._async_handle_event()
return None
webhook_register(
self.hass,
DOMAIN,
f"Ekey {self._attr_name}",
self._webhook_id,
async_webhook_handler,
allowed_methods=[METH_POST],
)
async def async_will_remove_from_hass(self) -> None:
"""Unregister Webhook."""
webhook_unregister(self.hass, self._webhook_id)

View File

@@ -0,0 +1,11 @@
{
"domain": "ekeybionyx",
"name": "ekey bionyx",
"codeowners": ["@richardpolzer"],
"config_flow": true,
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/ekeybionyx",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["ekey-bionyxpy==1.0.0"]
}

View File

@@ -0,0 +1,92 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide actions.
appropriate-polling:
status: exempt
comment: This integration does not poll.
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: 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:
status: exempt
comment: This integration does not connect to any device or service.
test-before-configure: done
test-before-setup:
status: exempt
comment: This integration does not connect to any device or service.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable:
status: exempt
comment: This integration has no way of knowing if the fingerprint reader is offline.
integration-owner: done
log-when-unavailable:
status: exempt
comment: This integration has no way of knowing if the fingerprint reader is offline.
parallel-updates:
status: exempt
comment: This integration does not poll.
reauthentication-flow:
status: exempt
comment: This integration does not store the tokens.
test-coverage: todo
# Gold
devices:
status: exempt
comment: This integration does not connect to any device or service.
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration does not support discovery.
discovery:
status: exempt
comment: This integration does not support discovery.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: This integration does not connect to any device or service.
entity-category: todo
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: This integration has no entities that should be disabled by default.
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: This integration does not connect to any device or service.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,66 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"choose_system": {
"data": {
"system": "System"
},
"data_description": {
"system": "System the event entities should be set up for."
},
"description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant."
},
"webhooks": {
"description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.",
"data": {
"webhook1": "Event entity 1",
"webhook2": "Event entity 2",
"webhook3": "Event entity 3",
"webhook4": "Event entity 4",
"webhook5": "Event entity 5",
"url": "Home Assistant URL"
},
"data_description": {
"webhook1": "Name of event entity 1 that will be mapped into a function",
"webhook2": "Name of event entity 2 that will be mapped into a function",
"webhook3": "Name of event entity 3 that will be mapped into a function",
"webhook4": "Name of event entity 4 that will be mapped into a function",
"webhook5": "Name of event entity 5 that will be mapped into a function",
"url": "Home Assistant instance URL which can be reached from the fingerprint controller"
}
},
"delete_webhooks": {
"description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted."
}
},
"progress": {
"check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions."
},
"error": {
"invalid_name": "Name is invalid",
"invalid_url": "URL is invalid",
"no_webhooks_provided": "No event names provided"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.",
"no_own_systems": "Your account does not have admin access to any systems.",
"cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250903.5"]
"requirements": ["home-assistant-frontend==20250924.0"]
}

View File

@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.data_entry_flow import AbortFlow, progress_step
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
@@ -72,8 +72,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
_failed_addon_name: str
_failed_addon_reason: str
_picked_firmware_type: PickedFirmwareType
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -85,8 +83,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self._hardware_name: str = "unknown" # To be set in a subclass
self._zigbee_integration = ZigbeeIntegration.ZHA
self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task[None] | None = None
self.installing_firmware_name: str | None = None
@@ -127,8 +123,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
) -> ConfigFlowResult:
"""Pick Thread or Zigbee firmware."""
# Determine if ZHA or Thread are already configured to present migrate options
zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN)
otbr_entries = self.hass.config_entries.async_entries(OTBR_DOMAIN)
zha_entries = self.hass.config_entries.async_entries(
ZHA_DOMAIN, include_ignore=False
)
otbr_entries = self.hass.config_entries.async_entries(
OTBR_DOMAIN, include_ignore=False
)
return self.async_show_menu(
step_id="pick_firmware",
@@ -486,18 +486,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Zigbee firmware."""
raise NotImplementedError
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_pre_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -561,6 +549,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware."""
raise NotImplementedError
@progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -570,70 +564,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.debug("OTBR addon info: %s", addon_info)
if not self.addon_install_task:
self.addon_install_task = self.hass.async_create_task(
addon_manager.async_install_addon_waiting(),
"OTBR addon install",
)
if not self.addon_install_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
progress_action="install_addon",
try:
await addon_manager.async_install_addon_waiting()
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_install_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
progress_task=self.addon_install_task,
)
) from err
try:
await self.addon_install_task
except AddonError as err:
_LOGGER.error(err)
self._failed_addon_name = addon_manager.addon_name
self._failed_addon_reason = "addon_install_failed"
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_install_task = None
return self.async_show_progress_done(next_step_id="finish_thread_installation")
return await self.async_step_finish_thread_installation()
@progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
otbr_manager = get_otbr_addon_manager(self.hass)
if not self.addon_start_task:
self.addon_start_task = self.hass.async_create_task(
self._configure_and_start_otbr_addon()
)
if not self.addon_start_task.done():
return self.async_show_progress(
step_id="start_otbr_addon",
progress_action="start_otbr_addon",
try:
await self._configure_and_start_otbr_addon()
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_start_failed",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": otbr_manager.addon_name,
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
},
progress_task=self.addon_start_task,
)
) from err
try:
await self.addon_start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = otbr_manager.addon_name
self._failed_addon_reason = (
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
)
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
return await self.async_step_pre_confirm_otbr()
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None
@@ -641,20 +608,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Pre-confirm OTBR setup."""
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_confirm_otbr()
async def async_step_confirm_otbr(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm OTBR setup."""
assert self._device is not None
if user_input is None:
return self.async_show_form(
step_id="confirm_otbr",
description_placeholders=self._get_translation_placeholders(),
)
# OTBR discovery is done automatically via hassio
return self._async_flow_finished()

View File

@@ -63,7 +63,7 @@ async def async_get_controller(hass: HomeAssistant) -> Controller:
controller = Controller(
async_zeroconf_instance=async_zeroconf_instance,
bleak_scanner_instance=bleak_scanner_instance, # type: ignore[arg-type]
bleak_scanner_instance=bleak_scanner_instance,
char_cache=char_cache,
)

View File

@@ -1,28 +0,0 @@
"""Analytics platform."""
from homeassistant.components.analytics import (
AnalyticsInput,
AnalyticsModifications,
EntityAnalyticsModifications,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
ent_reg = er.async_get(hass)
entities: dict[str, EntityAnalyticsModifications] = {}
for entity_id in analytics_input.entity_ids:
entity_entry = ent_reg.entities[entity_id]
if entity_entry.capabilities is not None:
capabilities = dict(entity_entry.capabilities)
capabilities["options"] = len(capabilities["options"])
entities[entity_id] = EntityAnalyticsModifications(
capabilities=capabilities
)
return AnalyticsModifications(entities=entities)

View File

@@ -25,6 +25,7 @@ from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,

View File

@@ -20,6 +20,14 @@
}
}
},
"number": {
"light_brightness": {
"default": "mdi:brightness-5"
},
"plant_days": {
"default": "mdi:calendar-blank"
}
},
"select": {
"display_temperature_unit": {
"default": "mdi:thermometer-lines"

View File

@@ -0,0 +1,136 @@
"""Support for LetPot number entities."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from letpot.deviceclient import LetPotDeviceClient
from letpot.models import DeviceFeature
from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import PRECISION_WHOLE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LetPotNumberEntityDescription(LetPotEntityDescription, NumberEntityDescription):
"""Describes a LetPot number entity."""
max_value_fn: Callable[[LetPotDeviceCoordinator], float]
value_fn: Callable[[LetPotDeviceCoordinator], float | None]
set_value_fn: Callable[[LetPotDeviceClient, str, float], Coroutine[Any, Any, None]]
NUMBERS: tuple[LetPotNumberEntityDescription, ...] = (
LetPotNumberEntityDescription(
key="light_brightness_levels",
translation_key="light_brightness",
value_fn=(
lambda coordinator: coordinator.device_client.get_light_brightness_levels(
coordinator.device.serial_number
).index(coordinator.data.light_brightness)
+ 1
if coordinator.data.light_brightness is not None
else None
),
set_value_fn=(
lambda device_client, serial, value: device_client.set_light_brightness(
serial,
device_client.get_light_brightness_levels(serial)[int(value) - 1],
)
),
supported_fn=(
lambda coordinator: DeviceFeature.LIGHT_BRIGHTNESS_LEVELS
in coordinator.device_client.device_info(
coordinator.device.serial_number
).features
),
native_min_value=float(1),
max_value_fn=lambda coordinator: float(
len(
coordinator.device_client.get_light_brightness_levels(
coordinator.device.serial_number
)
)
),
native_step=PRECISION_WHOLE,
mode=NumberMode.SLIDER,
entity_category=EntityCategory.CONFIG,
),
LetPotNumberEntityDescription(
key="plant_days",
translation_key="plant_days",
value_fn=lambda coordinator: coordinator.data.plant_days,
set_value_fn=(
lambda device_client, serial, value: device_client.set_plant_days(
serial, int(value)
)
),
native_min_value=float(0),
max_value_fn=lambda _: float(999),
native_step=PRECISION_WHOLE,
mode=NumberMode.BOX,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot number entities based on a config entry and device status/features."""
coordinators = entry.runtime_data
async_add_entities(
LetPotNumberEntity(coordinator, description)
for description in NUMBERS
for coordinator in coordinators
if description.supported_fn(coordinator)
)
class LetPotNumberEntity(LetPotEntity, NumberEntity):
"""Defines a LetPot number entity."""
entity_description: LetPotNumberEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotNumberEntityDescription,
) -> None:
"""Initialize LetPot number entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def native_max_value(self) -> float:
"""Return the maximum available value."""
return self.entity_description.max_value_fn(self.coordinator)
@property
def native_value(self) -> float | None:
"""Return the number value."""
return self.entity_description.value_fn(self.coordinator)
@exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Change the number value."""
return await self.entity_description.set_value_fn(
self.coordinator.device_client,
self.coordinator.device.serial_number,
value,
)

View File

@@ -49,6 +49,15 @@
"name": "Refill error"
}
},
"number": {
"light_brightness": {
"name": "Light brightness"
},
"plant_days": {
"name": "Plants age",
"unit_of_measurement": "days"
}
},
"select": {
"display_temperature_unit": {
"name": "Temperature unit on display",
@@ -58,7 +67,7 @@
}
},
"light_brightness": {
"name": "Light brightness",
"name": "[%key:component::letpot::entity::number::light_brightness::name%]",
"state": {
"low": "[%key:common::state::low%]",
"high": "[%key:common::state::high%]"

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Protocol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.integration_platform import (
@@ -72,6 +73,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Local sources support
await _process_media_source_platform(hass, DOMAIN, local_source)
hass.http.register_view(local_source.UploadMediaView)
websocket_api.async_register_command(hass, local_source.websocket_remove_media)
await async_process_integration_platforms(
hass, DOMAIN, _process_media_source_platform

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
@@ -17,19 +16,14 @@ from homeassistant.components.media_player import (
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant
from .const import MEDIA_SOURCE_DATA
from .error import Unresolvable
from .helper import async_browse_media, async_resolve_media
from .models import MediaSourceItem
LOGGER = logging.getLogger(__name__)
def async_setup(hass: HomeAssistant) -> None:
"""Set up the HTTP views and WebSocket commands for media sources."""
websocket_api.async_register_command(hass, websocket_browse_media)
websocket_api.async_register_command(hass, websocket_resolve_media)
websocket_api.async_register_command(hass, websocket_remove_media)
frontend.async_register_built_in_panel(
hass, "media-browser", "media_browser", "hass:play-box-multiple"
)
@@ -83,46 +77,3 @@ async def websocket_resolve_media(
"mime_type": media.mime_type,
},
)
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/remove_media",
vol.Required("media_content_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_remove_media(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Remove media."""
try:
item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None)
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
if item.domain is None:
connection.send_error(
msg["id"],
websocket_api.ERR_INVALID_FORMAT,
"Media source domain required",
)
return
source = hass.data[MEDIA_SOURCE_DATA][item.domain]
try:
await source.async_delete_media(item)
except NotImplementedError:
connection.send_error(
msg["id"], websocket_api.ERR_NOT_SUPPORTED, "Delete not supported"
)
except Unresolvable as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
except Exception as err: # pylint: disable=broad-except
LOGGER.exception("Unexpected error removing media")
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -7,13 +7,13 @@ import logging
import mimetypes
from pathlib import Path
import shutil
from typing import Protocol, cast
from typing import Any, Protocol, cast
from aiohttp import web
from aiohttp.web_request import FileField
import voluptuous as vol
from homeassistant.components import http
from homeassistant.components import http, websocket_api
from homeassistant.components.http import require_admin
from homeassistant.components.media_player import BrowseError, MediaClass
from homeassistant.core import HomeAssistant, callback
@@ -28,6 +28,10 @@ MAX_UPLOAD_SIZE = 1024 * 1024 * 10
LOGGER = logging.getLogger(__name__)
class PathNotSupportedError(HomeAssistantError):
"""Error to indicate a path is not supported."""
class InvalidFileNameError(HomeAssistantError):
"""Error to indicate an invalid file name."""
@@ -98,10 +102,10 @@ class LocalSource(MediaSource):
def _do_delete() -> None:
if not item_path.exists():
raise Unresolvable("Path does not exist")
raise FileNotFoundError("Path does not exist")
if not item_path.is_file():
raise Unresolvable("Path is not a file")
raise PathNotSupportedError("Path is not a file")
item_path.unlink()
@@ -137,7 +141,7 @@ class LocalSource(MediaSource):
target_dir.mkdir(parents=True, exist_ok=True)
except ValueError as err:
raise Unresolvable("Invalid path") from err
raise PathNotSupportedError("Invalid path") from err
with target_path.open("wb") as target_fp:
shutil.copyfileobj(uploaded_file.file, target_fp)
@@ -376,8 +380,51 @@ class UploadMediaView(http.HomeAssistantView):
except InvalidFileNameError as err:
LOGGER.error("Invalid filename uploaded: %s", data["file"].filename)
raise web.HTTPBadRequest from err
except PathNotSupportedError as err:
LOGGER.error("Invalid path for upload: %s", data["media_content_id"])
raise web.HTTPBadRequest from err
except OSError as err:
LOGGER.error("Error uploading file: %s", err)
raise web.HTTPInternalServerError from err
return self.json({"media_content_id": uploaded_media_source_id})
@websocket_api.websocket_command(
{
vol.Required("type"): "media_source/local_source/remove",
vol.Required("media_content_id"): str,
}
)
@websocket_api.require_admin
@websocket_api.async_response
async def websocket_remove_media(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Remove media."""
try:
item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None)
except ValueError as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
return
if item.domain != DOMAIN:
connection.send_error(
msg["id"], websocket_api.ERR_INVALID_FORMAT, "Invalid media source domain"
)
return
source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][item.domain])
try:
await source.async_delete_media(item)
except Unresolvable as err:
connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err))
except FileNotFoundError as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, str(err))
except PathNotSupportedError as err:
connection.send_error(msg["id"], websocket_api.ERR_NOT_SUPPORTED, str(err))
except OSError as err:
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
else:
connection.send_result(msg["id"])

View File

@@ -27,12 +27,7 @@ class BrowseMediaSource(BrowseMedia):
"""Represent a browsable media file."""
def __init__(
self,
*,
domain: str | None,
identifier: str | None,
can_delete: bool = False,
**kwargs: Any,
self, *, domain: str | None, identifier: str | None, **kwargs: Any
) -> None:
"""Initialize media source browse media."""
media_content_id = f"{URI_SCHEME}{domain or ''}"
@@ -43,13 +38,6 @@ class BrowseMediaSource(BrowseMedia):
self.domain = domain
self.identifier = identifier
self.can_delete = can_delete
def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert BrowseMediaSource to a dictionary."""
response = super().as_dict(parent=parent)
response["can_delete"] = self.can_delete
return response
@dataclass(slots=True)
@@ -147,7 +135,3 @@ class MediaSource:
async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource:
"""Browse media."""
raise NotImplementedError
async def async_delete_media(self, item: MediaSourceItem) -> None:
"""Delete media."""
raise NotImplementedError

View File

@@ -117,7 +117,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
conv_brightness = self._convert_brightness_to_modbus(brightness)
await self._hub.async_pb_call(
device_address=self._device_address,
unit=self._device_address,
address=self._brightness_address,
value=conv_brightness,
use_call=CALL_TYPE_WRITE_REGISTER,
@@ -133,7 +133,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin)
await self._hub.async_pb_call(
device_address=self._device_address,
unit=self._device_address,
address=self._color_temp_address,
value=conv_color_temp_kelvin,
use_call=CALL_TYPE_WRITE_REGISTER,
@@ -150,7 +150,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
if self._brightness_address:
brightness_result = await self._hub.async_pb_call(
device_address=self._device_address,
unit=self._device_address,
value=1,
address=self._brightness_address,
use_call=CALL_TYPE_REGISTER_HOLDING,
@@ -167,7 +167,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
if self._color_temp_address:
color_result = await self._hub.async_pb_call(
device_address=self._device_address,
unit=self._device_address,
value=1,
address=self._color_temp_address,
use_call=CALL_TYPE_REGISTER_HOLDING,

View File

@@ -1,7 +1,7 @@
{
"domain": "modbus",
"name": "Modbus",
"codeowners": ["@janiversen"],
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/modbus",
"iot_class": "local_polling",
"loggers": ["pymodbus"],

View File

@@ -370,17 +370,11 @@ class ModbusHub:
_LOGGER.info(f"modbus {self.name} communication closed")
async def low_level_pb_call(
self,
device_address: int | None,
address: int,
value: int | list[int],
use_call: str,
self, slave: int | None, address: int, value: int | list[int], use_call: str
) -> ModbusPDU | None:
"""Call sync. pymodbus."""
kwargs: dict[str, Any] = (
{DEVICE_ID: device_address}
if device_address is not None
else {DEVICE_ID: 1}
{DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1}
)
entry = self._pb_request[use_call]
@@ -392,26 +386,28 @@ class ModbusHub:
try:
result: ModbusPDU = await entry.func(address, **kwargs)
except ModbusException as exception_error:
error = f"Error: device: {device_address} address: {address} -> {exception_error!s}"
error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
self._log_error(error)
return None
if not result:
error = f"Error: device: {device_address} address: {address} -> pymodbus returned None"
error = (
f"Error: device: {slave} address: {address} -> pymodbus returned None"
)
self._log_error(error)
return None
if not hasattr(result, entry.attr):
error = f"Error: device: {device_address} address: {address} -> {result!s}"
error = f"Error: device: {slave} address: {address} -> {result!s}"
self._log_error(error)
return None
if result.isError():
error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True"
error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True"
self._log_error(error)
return None
return result
async def async_pb_call(
self,
device_address: int | None,
unit: int | None,
address: int,
value: int | list[int],
use_call: str,
@@ -419,7 +415,7 @@ class ModbusHub:
"""Convert async to sync pymodbus call."""
if not self._client:
return None
result = await self.low_level_pb_call(device_address, address, value, use_call)
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
await asyncio.sleep(self._msg_wait)
return result

View File

@@ -38,7 +38,10 @@ from homeassistant.core import (
get_hassjob_callable_job_type,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import ConfigType
@@ -71,6 +74,7 @@ from .const import (
DEFAULT_WS_PATH,
DOMAIN,
MQTT_CONNECTION_STATE,
MQTT_PROCESSED_SUBSCRIPTIONS,
PROTOCOL_5,
PROTOCOL_31,
TRANSPORT_WEBSOCKETS,
@@ -109,6 +113,7 @@ INITIAL_SUBSCRIBE_COOLDOWN = 0.5
SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
SUBSCRIBE_TIMEOUT = 10
RECONNECT_INTERVAL_SECONDS = 10
MAX_WILDCARD_SUBSCRIBES_PER_CALL = 1
@@ -191,11 +196,47 @@ async def async_subscribe(
msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None],
qos: int = DEFAULT_QOS,
encoding: str | None = DEFAULT_ENCODING,
wait: bool = False,
) -> CALLBACK_TYPE:
"""Subscribe to an MQTT topic.
Call the return value to unsubscribe.
"""
subscription_complete: asyncio.Future[None]
async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None:
if (topic, qos) not in subscriptions:
return
subscription_complete.set_result(None)
def _async_timeout_subscribe() -> None:
if not subscription_complete.done():
subscription_complete.set_exception(TimeoutError)
if (
wait
and DATA_MQTT in hass.data
and not hass.data[DATA_MQTT].client._matching_subscriptions(topic) # noqa: SLF001
):
subscription_complete = hass.loop.create_future()
dispatcher = async_dispatcher_connect(
hass, MQTT_PROCESSED_SUBSCRIPTIONS, _sync_mqtt_subscribe
)
subscribe_callback = async_subscribe_internal(
hass, topic, msg_callback, qos, encoding
)
try:
hass.loop.call_later(SUBSCRIBE_TIMEOUT, _async_timeout_subscribe)
await subscription_complete
except TimeoutError as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="subscribe_timeout",
) from exc
finally:
dispatcher()
return subscribe_callback
return async_subscribe_internal(hass, topic, msg_callback, qos, encoding)
@@ -963,6 +1004,7 @@ class MQTT:
self._last_subscribe = time.monotonic()
await self._async_wait_for_mid_or_raise(mid, result)
async_dispatcher_send(self.hass, MQTT_PROCESSED_SUBSCRIPTIONS, chunk_list)
async def _async_perform_unsubscribes(self) -> None:
"""Perform pending MQTT client unsubscribes."""

View File

@@ -370,6 +370,7 @@ DOMAIN = "mqtt"
LOGGER = logging.getLogger(__package__)
MQTT_CONNECTION_STATE = "mqtt_connection_state"
MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["file_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/mqtt",
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["paho-mqtt==2.1.0"],

View File

@@ -0,0 +1 @@
"""Neo virtual integration."""

View File

@@ -0,0 +1,6 @@
{
"domain": "neo",
"name": "Neo",
"integration_type": "virtual",
"supported_by": "shelly"
}

View File

@@ -473,7 +473,12 @@ class TopicSubentryFlowHandler(ConfigSubentryFlow):
return self.async_update_and_abort(
entry=entry,
subentry=subentry,
data_updates=user_input,
data_updates={
CONF_PRIORITY: user_input.get(CONF_PRIORITY),
CONF_TAGS: user_input.get(CONF_TAGS),
CONF_TITLE: user_input.get(CONF_TITLE),
CONF_MESSAGE: user_input.get(CONF_MESSAGE),
},
)
return self.async_show_form(

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.4.0"]
"requirements": ["renault-api==0.4.1"]
}

View File

@@ -243,8 +243,45 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
await super().async_will_remove_from_hass()
class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Parent class for Reolink chime entities connected to a Host."""
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
super().__init__(reolink_data, coordinator)
self._channel = chime.channel
self._chime = chime
self._attr_unique_id = (
f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
)
via_dev_id = self._host.unique_id
self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, via_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
sw_version=chime.sw_version,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._chime.online
class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
"""Parent class for Reolink chime entities connected."""
"""Parent class for Reolink chime entities connected through a camera."""
def __init__(
self,
@@ -255,21 +292,21 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
assert chime.channel is not None
super().__init__(reolink_data, chime.channel, coordinator)
self._chime = chime
self._attr_unique_id = (
f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
)
cam_dev_id = self._dev_id
via_dev_id = self._dev_id
self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, cam_dev_id),
via_device=(DOMAIN, via_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
sw_version=chime.sw_version,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)

View File

@@ -23,6 +23,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -855,6 +856,12 @@ async def async_setup_entry(
for chime in api.chime_list
if chime.channel is not None
)
entities.extend(
ReolinkHostChimeNumberEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_NUMBER_ENTITIES
for chime in api.chime_list
if chime.channel is None
)
async_add_entities(entities)
@@ -969,7 +976,36 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity):
class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink IP cameras."""
"""Base number entity class for Reolink chimes connected through a camera."""
entity_description: ReolinkChimeNumberEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeNumberEntityDescription,
) -> None:
"""Initialize Reolink chime number entity."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
self._attr_mode = entity_description.mode
@property
def native_value(self) -> float | None:
"""State of the number entity."""
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await self.entity_description.method(self._chime, value)
self.async_write_ha_state()
class ReolinkHostChimeNumberEntity(ReolinkHostChimeCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink chimes connected to the host."""
entity_description: ReolinkChimeNumberEntityDescription

View File

@@ -31,6 +31,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -73,7 +74,7 @@ class ReolinkChimeSelectEntityDescription(
get_options: list[str]
method: Callable[[Chime, str], Any]
value: Callable[[Chime], str]
value: Callable[[Chime], str | None]
def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
@@ -332,7 +333,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
supported=lambda chime: "md" in chime.chime_event_types,
get_options=[method.name for method in ChimeToneEnum],
value=lambda chime: ChimeToneEnum(chime.tone("md")).name,
value=lambda chime: chime.tone_name("md"),
method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -342,7 +343,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "people" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
value=lambda chime: chime.tone_name("people"),
method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -352,7 +353,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "vehicle" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name,
value=lambda chime: chime.tone_name("vehicle"),
method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -362,7 +363,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "visitor" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name,
value=lambda chime: chime.tone_name("visitor"),
method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -372,7 +373,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "package" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("package")).name,
value=lambda chime: chime.tone_name("package"),
method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value),
),
)
@@ -386,9 +387,7 @@ async def async_setup_entry(
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = config_entry.runtime_data
entities: list[
ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity
] = [
entities: list[SelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
@@ -405,6 +404,12 @@ async def async_setup_entry(
for chime in reolink_data.host.api.chime_list
if entity_description.supported(chime) and chime.channel is not None
)
entities.extend(
ReolinkHostChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES
for chime in reolink_data.host.api.chime_list
if entity_description.supported(chime) and chime.channel is None
)
async_add_entities(entities)
@@ -481,7 +486,7 @@ class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity):
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink IP cameras."""
"""Base select entity class for Reolink chimes connected through a camera."""
entity_description: ReolinkChimeSelectEntityDescription
@@ -494,22 +499,40 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Initialize Reolink select entity for a chime."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
self._log_error = True
self._attr_options = entity_description.get_options
@property
def current_option(self) -> str | None:
"""Return the current option."""
try:
option = self.entity_description.value(self._chime)
except (ValueError, KeyError):
if self._log_error:
_LOGGER.exception("Reolink '%s' has an unknown value", self.name)
self._log_error = False
return None
self._log_error = True
return option
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.method(self._chime, option)
self.async_write_ha_state()
class ReolinkHostChimeSelectEntity(ReolinkHostChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink chimes connected to a host."""
entity_description: ReolinkChimeSelectEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeSelectEntityDescription,
) -> None:
"""Initialize Reolink select entity for a chime."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
self._attr_options = entity_description.get_options
@property
def current_option(self) -> str | None:
"""Return the current option."""
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_select_option(self, option: str) -> None:

View File

@@ -20,6 +20,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -364,9 +365,7 @@ async def async_setup_entry(
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = config_entry.runtime_data
entities: list[
ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity
] = [
entities: list[SwitchEntity] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
@@ -383,6 +382,12 @@ async def async_setup_entry(
for chime in reolink_data.host.api.chime_list
if chime.channel is not None
)
entities.extend(
ReolinkHostChimeSwitchEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SWITCH_ENTITIES
for chime in reolink_data.host.api.chime_list
if chime.channel is None
)
# Can be removed in HA 2025.4.0
depricated_dict = {}
@@ -511,3 +516,36 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
"""Turn the entity off."""
await self.entity_description.method(self._chime, False)
self.async_write_ha_state()
class ReolinkHostChimeSwitchEntity(ReolinkHostChimeCoordinatorEntity, SwitchEntity):
"""Base switch entity class for a chime."""
entity_description: ReolinkChimeSwitchEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeSwitchEntityDescription,
) -> None:
"""Initialize Reolink switch entity."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.entity_description.method(self._chime, True)
self.async_write_ha_state()
@raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_description.method(self._chime, False)
self.async_write_ha_state()

View File

@@ -0,0 +1,28 @@
"""The Smart Meter B Route integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import BRouteConfigEntry, BRouteUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool:
"""Set up Smart Meter B Route from a config entry."""
coordinator = BRouteUpdateCoordinator(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: BRouteConfigEntry) -> bool:
"""Unload a config entry."""
await hass.async_add_executor_job(entry.runtime_data.api.close)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,116 @@
"""Config flow for Smart Meter B Route integration."""
import logging
from typing import Any
from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure
from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant.components.usb import get_serial_by_id, human_readable_device_name
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD
from homeassistant.core import callback
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import DOMAIN, ENTRY_TITLE
_LOGGER = logging.getLogger(__name__)
def _validate_input(device: str, id: str, password: str) -> None:
"""Validate the user input allows us to connect."""
with Momonga(dev=device, rbid=id, pwd=password):
pass
def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str:
return human_readable_device_name(
port.device,
port.serial_number,
port.manufacturer,
port.description,
str(port.vid) if port.vid else None,
str(port.pid) if port.pid else None,
)
class BRouteConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Smart Meter B Route."""
VERSION = 1
device: UsbServiceInfo | None = None
@callback
def _get_discovered_device_id_and_name(
self, device_options: dict[str, ListPortInfo]
) -> tuple[str | None, str | None]:
discovered_device_id = (
get_serial_by_id(self.device.device) if self.device else None
)
discovered_device = (
device_options.get(discovered_device_id) if discovered_device_id else None
)
discovered_device_name = (
_human_readable_device_name(discovered_device)
if discovered_device
else None
)
return discovered_device_id, discovered_device_name
async def _get_usb_devices(self) -> dict[str, ListPortInfo]:
"""Return a list of available USB devices."""
devices = await self.hass.async_add_executor_job(comports)
return {get_serial_by_id(port.device): port for port in devices}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
device_options = await self._get_usb_devices()
if user_input is not None:
try:
await self.hass.async_add_executor_job(
_validate_input,
user_input[CONF_DEVICE],
user_input[CONF_ID],
user_input[CONF_PASSWORD],
)
except MomongaSkScanFailure:
errors["base"] = "cannot_connect"
except MomongaSkJoinFailure:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
user_input[CONF_ID], raise_on_progress=False
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=ENTRY_TITLE, data=user_input)
discovered_device_id, discovered_device_name = (
self._get_discovered_device_id_and_name(device_options)
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_DEVICE, default=discovered_device_id): vol.In(
{discovered_device_id: discovered_device_name}
if discovered_device_id and discovered_device_name
else {
name: _human_readable_device_name(device)
for name, device in device_options.items()
}
),
vol.Required(CONF_ID): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)

View File

@@ -0,0 +1,12 @@
"""Constants for the Smart Meter B Route integration."""
from datetime import timedelta
DOMAIN = "route_b_smart_meter"
ENTRY_TITLE = "Route B Smart Meter"
DEFAULT_SCAN_INTERVAL = timedelta(seconds=300)
ATTR_API_INSTANTANEOUS_POWER = "instantaneous_power"
ATTR_API_TOTAL_CONSUMPTION = "total_consumption"
ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE = "instantaneous_current_t_phase"
ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE = "instantaneous_current_r_phase"

View File

@@ -0,0 +1,75 @@
"""DataUpdateCoordinator for the Smart Meter B-route integration."""
from dataclasses import dataclass
import logging
from momonga import Momonga, MomongaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
@dataclass
class BRouteData:
"""Class for data of the B Route."""
instantaneous_current_r_phase: float
instantaneous_current_t_phase: float
instantaneous_power: float
total_consumption: float
type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator]
class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]):
"""The B Route update coordinator."""
def __init__(
self,
hass: HomeAssistant,
entry: BRouteConfigEntry,
) -> None:
"""Initialize."""
self.device = entry.data[CONF_DEVICE]
self.bid = entry.data[CONF_ID]
password = entry.data[CONF_PASSWORD]
self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password)
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=DEFAULT_SCAN_INTERVAL,
)
async def _async_setup(self) -> None:
await self.hass.async_add_executor_job(
self.api.open,
)
def _get_data(self) -> BRouteData:
"""Get the data from API."""
current = self.api.get_instantaneous_current()
return BRouteData(
instantaneous_current_r_phase=current["r phase current"],
instantaneous_current_t_phase=current["t phase current"],
instantaneous_power=self.api.get_instantaneous_power(),
total_consumption=self.api.get_measured_cumulative_energy(),
)
async def _async_update_data(self) -> BRouteData:
"""Update data."""
try:
return await self.hass.async_add_executor_job(self._get_data)
except MomongaError as error:
raise UpdateFailed(error) from error

View File

@@ -0,0 +1,17 @@
{
"domain": "route_b_smart_meter",
"name": "Smart Meter B Route",
"codeowners": ["@SeraphicRav"],
"config_flow": true,
"dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/route_b_smart_meter",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": [
"momonga.momonga",
"momonga.momonga_session_manager",
"momonga.sk_wrapper_logger"
],
"quality_scale": "bronze",
"requirements": ["pyserial==3.5", "momonga==0.1.5"]
}

View File

@@ -0,0 +1,82 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
The integration does not provide any additional actions.
appropriate-polling:
status: done
brands:
status: exempt
comment: |
The integration is not specific to a single brand, it does not have a logo.
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
The integration does not provide any additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
The integration does not use 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:
status: exempt
comment: |
The integration does not provide any additional actions.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery:
status: exempt
comment: |
The manufacturer does not use unique identifiers for devices.
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: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession:
status: exempt
comment: |
The integration does not use HTTP.
strict-typing: todo

View File

@@ -0,0 +1,109 @@
"""Smart Meter B Route."""
from collections.abc import Callable
from dataclasses import dataclass
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BRouteConfigEntry
from .const import (
ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
ATTR_API_INSTANTANEOUS_POWER,
ATTR_API_TOTAL_CONSUMPTION,
DOMAIN,
)
from .coordinator import BRouteData, BRouteUpdateCoordinator
@dataclass(frozen=True, kw_only=True)
class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription):
"""Sensor entity description with data accessor."""
value_accessor: Callable[[BRouteData], StateType]
SENSOR_DESCRIPTIONS = (
SensorEntityDescriptionWithValueAccessor(
key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
translation_key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_accessor=lambda data: data.instantaneous_current_r_phase,
),
SensorEntityDescriptionWithValueAccessor(
key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
translation_key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_accessor=lambda data: data.instantaneous_current_t_phase,
),
SensorEntityDescriptionWithValueAccessor(
key=ATTR_API_INSTANTANEOUS_POWER,
translation_key=ATTR_API_INSTANTANEOUS_POWER,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.WATT,
value_accessor=lambda data: data.instantaneous_power,
),
SensorEntityDescriptionWithValueAccessor(
key=ATTR_API_TOTAL_CONSUMPTION,
translation_key=ATTR_API_TOTAL_CONSUMPTION,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_accessor=lambda data: data.total_consumption,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: BRouteConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Smart Meter B-route entry."""
coordinator = entry.runtime_data
async_add_entities(
SmartMeterBRouteSensor(coordinator, description)
for description in SENSOR_DESCRIPTIONS
)
class SmartMeterBRouteSensor(CoordinatorEntity[BRouteUpdateCoordinator], SensorEntity):
"""Representation of a Smart Meter B-route sensor entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BRouteUpdateCoordinator,
description: SensorEntityDescriptionWithValueAccessor,
) -> None:
"""Initialize Smart Meter B-route sensor entity."""
super().__init__(coordinator)
self.entity_description: SensorEntityDescriptionWithValueAccessor = description
self._attr_unique_id = f"{coordinator.bid}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.bid)},
name=f"Route B Smart Meter {coordinator.bid}",
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_accessor(self.coordinator.data)

View File

@@ -0,0 +1,42 @@
{
"config": {
"step": {
"user": {
"data_description": {
"device": "[%key:common::config_flow::data::device%]",
"id": "B Route ID",
"password": "[%key:common::config_flow::data::password%]"
},
"data": {
"device": "[%key:common::config_flow::data::device%]",
"id": "B Route ID",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"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%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"sensor": {
"instantaneous_power": {
"name": "Instantaneous power"
},
"total_consumption": {
"name": "Total consumption"
},
"instantaneous_current_t_phase": {
"name": "Instantaneous current T phase"
},
"instantaneous_current_r_phase": {
"name": "Instantaneous current R phase"
}
}
}
}

View File

@@ -14,6 +14,9 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_TEMP,
DEFAULT_MIN_TEMP,
PRESET_BOOST,
PRESET_NONE,
PRESET_SLEEP,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
@@ -97,6 +100,19 @@ HEAT_PUMP_AC_MODE_TO_HA = {
"heat": HVACMode.HEAT,
}
PRESET_MODE_TO_HA = {
"off": PRESET_NONE,
"windFree": "wind_free",
"sleep": PRESET_SLEEP,
"windFreeSleep": "wind_free_sleep",
"speed": PRESET_BOOST,
"quiet": "quiet",
"longWind": "long_wind",
"smart": "smart",
}
HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()}
HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items()}
WIND = "wind"
@@ -362,6 +378,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
_attr_name = None
_attr_translation_key = "air_conditioner"
def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
@@ -582,9 +599,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.AC_OPTIONAL_MODE,
)
# Return the mode if it is in the supported modes
if self._attr_preset_modes and mode in self._attr_preset_modes:
return mode
return PRESET_MODE_TO_HA.get(mode)
return None
def _determine_preset_modes(self) -> list[str] | None:
@@ -594,8 +609,16 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Attribute.SUPPORTED_AC_OPTIONAL_MODE,
)
if supported_modes:
return supported_modes
modes = []
for mode in supported_modes:
if (ha_mode := PRESET_MODE_TO_HA.get(mode)) is not None:
modes.append(ha_mode)
else:
_LOGGER.warning(
"Unknown preset mode: %s, please report at https://github.com/home-assistant/core/issues",
mode,
)
return modes
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
@@ -603,7 +626,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
await self.execute_device_command(
Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
Command.SET_AC_OPTIONAL_MODE,
argument=preset_mode,
argument=HA_MODE_TO_PRESET_MODE[preset_mode],
)
def _determine_hvac_modes(self) -> list[HVACMode]:

View File

@@ -78,6 +78,21 @@
"name": "[%key:common::action::stop%]"
}
},
"climate": {
"air_conditioner": {
"state_attributes": {
"preset_mode": {
"state": {
"wind_free": "WindFree",
"wind_free_sleep": "WindFree sleep",
"quiet": "Quiet",
"long_wind": "Long wind",
"smart": "Smart"
}
}
}
}
},
"event": {
"button": {
"state": {
@@ -141,9 +156,9 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"low": "Low",
"low": "[%key:common::state::low%]",
"mid": "Mid",
"high": "High",
"high": "[%key:common::state::high%]",
"extra_high": "Extra high"
}
},
@@ -194,7 +209,7 @@
"state": {
"none": "None",
"heavy": "Heavy",
"normal": "Normal",
"normal": "[%key:common::state::normal%]",
"light": "Light",
"extra_light": "Extra light",
"extra_heavy": "Extra heavy",
@@ -626,7 +641,7 @@
"name": "Power freeze"
},
"auto_cycle_link": {
"name": "Auto cycle link"
"name": "Auto Cycle Link"
},
"sanitize": {
"name": "Sanitize"

View File

@@ -26,6 +26,7 @@ from homeassistant.components.media_player import (
ATTR_MEDIA_ARTIST,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
ATTR_MEDIA_TITLE,
BrowseMedia,
MediaPlayerDeviceClass,
@@ -538,26 +539,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
share_link = self.coordinator.share_link
if share_link.is_share_link(media_id):
if enqueue == MediaPlayerEnqueue.ADD:
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT
)
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = share_link.add_share_link_to_queue(
media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT
)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT
)
soco.play_from_queue(0)
title = kwargs.get(ATTR_MEDIA_EXTRA, {}).get("title", "")
self._play_media_sharelink(
soco=soco,
media_type=media_type,
media_id=media_id,
enqueue=enqueue,
title=title,
)
elif media_type == MEDIA_TYPE_DIRECTORY:
self._play_media_directory(
soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue
@@ -663,6 +652,39 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
)
self._play_media_queue(soco, item, enqueue)
def _play_media_sharelink(
self,
soco: SoCo,
media_type: MediaType | str,
media_id: str,
enqueue: MediaPlayerEnqueue,
title: str,
) -> None:
share_link = self.coordinator.share_link
kwargs = {}
if title:
kwargs["dc_title"] = title
if enqueue == MediaPlayerEnqueue.ADD:
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs
)
elif enqueue in (
MediaPlayerEnqueue.NEXT,
MediaPlayerEnqueue.PLAY,
):
pos = (self.media.queue_position or 0) + 1
new_pos = share_link.add_share_link_to_queue(
media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT, **kwargs
)
if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1)
elif enqueue == MediaPlayerEnqueue.REPLACE:
soco.clear_queue()
share_link.add_share_link_to_queue(
media_id, timeout=LONG_SERVICE_TIMEOUT, **kwargs
)
soco.play_from_queue(0)
@soco_error()
def set_sleep_timer(self, sleep_time: int) -> None:
"""Set the timer on the player."""

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from itertools import chain
import logging
from typing import TYPE_CHECKING
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
from synology_dsm.api.surveillance_station.camera import SynoCamera
@@ -177,10 +178,12 @@ async def async_remove_config_entry_device(
"""Remove synology_dsm config entry from a device."""
data = entry.runtime_data
api = data.api
assert api.information is not None
if TYPE_CHECKING:
assert api.information is not None
serial = api.information.serial
storage = api.storage
assert storage is not None
if TYPE_CHECKING:
assert storage is not None
all_cameras: list[SynoCamera] = []
if api.surveillance_station is not None:
# get_all_cameras does not do I/O

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from synology_dsm.api.core.security import SynoCoreSecurity
from synology_dsm.api.storage.storage import SynoStorage
@@ -68,7 +69,8 @@ async def async_setup_entry(
data = entry.runtime_data
api = data.api
coordinator = data.coordinator_central
assert api.storage is not None
if TYPE_CHECKING:
assert api.storage is not None
entities: list[SynoDSMSecurityBinarySensor | SynoDSMStorageBinarySensor] = [
SynoDSMSecurityBinarySensor(api, coordinator, description)
@@ -121,7 +123,8 @@ class SynoDSMSecurityBinarySensor(SynoDSMBinarySensor):
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return security checks details."""
assert self._api.security is not None
if TYPE_CHECKING:
assert self._api.security is not None
return self._api.security.status_by_check

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
import logging
from typing import Any, Final
from typing import TYPE_CHECKING, Any, Final
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -72,8 +72,9 @@ class SynologyDSMButton(ButtonEntity):
"""Initialize the Synology DSM binary_sensor entity."""
self.entity_description = description
self.syno_api = api
assert api.network is not None
assert api.information is not None
if TYPE_CHECKING:
assert api.network is not None
assert api.information is not None
self._attr_name = f"{api.network.hostname} {description.name}"
self._attr_unique_id = f"{api.information.serial}_{description.key}"
self._attr_device_info = DeviceInfo(
@@ -82,7 +83,8 @@ class SynologyDSMButton(ButtonEntity):
async def async_press(self) -> None:
"""Triggers the Synology DSM button press service."""
assert self.syno_api.network is not None
if TYPE_CHECKING:
assert self.syno_api.network is not None
LOGGER.debug(
"Trigger %s for %s",
self.entity_description.key,

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation
from synology_dsm.exceptions import (
@@ -94,7 +95,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
def device_info(self) -> DeviceInfo:
"""Return the device information."""
information = self._api.information
assert information is not None
if TYPE_CHECKING:
assert information is not None
return DeviceInfo(
identifiers={(DOMAIN, f"{information.serial}_{self.camera_data.id}")},
name=self.camera_data.name,
@@ -129,7 +131,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
_LOGGER.debug("Update stream URL for camera %s", self.camera_data.name)
self.stream.update_source(url)
assert self.platform.config_entry
if TYPE_CHECKING:
assert self.platform.config_entry
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -153,7 +156,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
)
if not self.available:
return None
assert self._api.surveillance_station is not None
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
try:
return await self._api.surveillance_station.get_camera_image(
self.entity_description.camera_id, self.snapshot_quality
@@ -187,7 +191,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
"SynoDSMCamera.enable_motion_detection(%s)",
self.camera_data.name,
)
assert self._api.surveillance_station is not None
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
await self._api.surveillance_station.enable_motion_detection(
self.entity_description.camera_id
)
@@ -198,7 +203,8 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C
"SynoDSMCamera.disable_motion_detection(%s)",
self.camera_data.name,
)
assert self._api.surveillance_station is not None
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
await self._api.surveillance_station.disable_motion_detection(
self.entity_description.camera_id
)

View File

@@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any, Concatenate
from typing import TYPE_CHECKING, Any, Concatenate
from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import (
@@ -110,14 +110,16 @@ class SynologyDSMSwitchUpdateCoordinator(
async def async_setup(self) -> None:
"""Set up the coordinator initial data."""
info = await self.api.dsm.surveillance_station.get_info()
assert info is not None
if TYPE_CHECKING:
assert info is not None
self.version = info["data"]["CMSMinVersion"]
@async_re_login_on_expired
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Fetch all data from api."""
surveillance_station = self.api.surveillance_station
assert surveillance_station is not None
if TYPE_CHECKING:
assert surveillance_station is not None
return {
"switches": {
"home_mode": bool(await surveillance_station.get_home_mode_status())
@@ -161,7 +163,8 @@ class SynologyDSMCameraUpdateCoordinator(
async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]:
"""Fetch all camera data from api."""
surveillance_station = self.api.surveillance_station
assert surveillance_station is not None
if TYPE_CHECKING:
assert surveillance_station is not None
current_data: dict[int, SynoCamera] = {
camera.id: camera for camera in surveillance_station.get_all_cameras()
}

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -47,8 +47,9 @@ class SynologyDSMBaseEntity[_CoordinatorT: SynologyDSMUpdateCoordinator[Any]](
self._api = api
information = api.information
network = api.network
assert information is not None
assert network is not None
if TYPE_CHECKING:
assert information is not None
assert network is not None
self._attr_unique_id: str = (
f"{information.serial}_{description.api_key}:{description.key}"
@@ -94,14 +95,17 @@ class SynologyDSMDeviceEntity(
information = api.information
network = api.network
external_usb = api.external_usb
assert information is not None
assert storage is not None
assert network is not None
if TYPE_CHECKING:
assert information is not None
assert storage is not None
assert network is not None
if "volume" in description.key:
assert self._device_id is not None
if TYPE_CHECKING:
assert self._device_id is not None
volume = storage.get_volume(self._device_id)
assert volume is not None
if TYPE_CHECKING:
assert volume is not None
# Volume does not have a name
self._device_name = volume["id"].replace("_", " ").capitalize()
self._device_manufacturer = "Synology"
@@ -114,17 +118,20 @@ class SynologyDSMDeviceEntity(
.replace("shr", "SHR")
)
elif "disk" in description.key:
assert self._device_id is not None
if TYPE_CHECKING:
assert self._device_id is not None
disk = storage.get_disk(self._device_id)
assert disk is not None
if TYPE_CHECKING:
assert disk is not None
self._device_name = disk["name"]
self._device_manufacturer = disk["vendor"]
self._device_model = disk["model"].strip()
self._device_firmware = disk["firm"]
self._device_type = disk["diskType"]
elif "device" in description.key:
assert self._device_id is not None
assert external_usb is not None
if TYPE_CHECKING:
assert self._device_id is not None
assert external_usb is not None
for device in external_usb.get_devices.values():
if device.device_name == self._device_id:
self._device_name = device.device_name
@@ -133,8 +140,9 @@ class SynologyDSMDeviceEntity(
self._device_type = device.device_type
break
elif "partition" in description.key:
assert self._device_id is not None
assert external_usb is not None
if TYPE_CHECKING:
assert self._device_id is not None
assert external_usb is not None
for device in external_usb.get_devices.values():
for partition in device.device_partitions.values():
if partition.partition_title == self._device_id:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from logging import getLogger
import mimetypes
from typing import TYPE_CHECKING
from aiohttp import web
from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem
@@ -121,9 +122,11 @@ class SynologyPhotosMediaSource(MediaSource):
DOMAIN, identifier.unique_id
)
)
assert entry
if TYPE_CHECKING:
assert entry
diskstation = entry.runtime_data
assert diskstation.api.photos is not None
if TYPE_CHECKING:
assert diskstation.api.photos is not None
if identifier.album_id is None:
# Get Albums
@@ -131,7 +134,8 @@ class SynologyPhotosMediaSource(MediaSource):
albums = await diskstation.api.photos.get_albums()
except SynologyDSMException:
return []
assert albums is not None
if TYPE_CHECKING:
assert albums is not None
ret = [
BrowseMediaSource(
@@ -190,7 +194,8 @@ class SynologyPhotosMediaSource(MediaSource):
)
except SynologyDSMException:
return []
assert album_items is not None
if TYPE_CHECKING:
assert album_items is not None
ret = []
for album_item in album_items:
@@ -249,7 +254,8 @@ class SynologyPhotosMediaSource(MediaSource):
self, item: SynoPhotosItem, diskstation: SynologyDSMData
) -> str | None:
"""Get thumbnail."""
assert diskstation.api.photos is not None
if TYPE_CHECKING:
assert diskstation.api.photos is not None
try:
thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item)
@@ -290,9 +296,11 @@ class SynologyDsmMediaView(http.HomeAssistantView):
DOMAIN, source_dir_id
)
)
assert entry
if TYPE_CHECKING:
assert entry
diskstation = entry.runtime_data
assert diskstation.api.photos is not None
if TYPE_CHECKING:
assert diskstation.api.photos is not None
item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase)
try:
if passphrase:

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import cast
from typing import TYPE_CHECKING, cast
from synology_dsm.api.core.external_usb import (
SynoCoreExternalUSB,
@@ -345,7 +345,8 @@ async def async_setup_entry(
api = data.api
coordinator = data.coordinator_central
storage = api.storage
assert storage is not None
if TYPE_CHECKING:
assert storage is not None
known_usb_devices: set[str] = set()
def _check_usb_devices() -> None:
@@ -504,7 +505,8 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor):
def native_value(self) -> StateType:
"""Return the state."""
external_usb = self._api.external_usb
assert external_usb is not None
if TYPE_CHECKING:
assert external_usb is not None
if "device" in self.entity_description.key:
for device in external_usb.get_devices.values():
if device.device_name == self._device_id:
@@ -523,6 +525,22 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor):
return attr # type: ignore[no-any-return]
@property
def available(self) -> bool:
"""Return True if entity is available."""
external_usb = self._api.external_usb
assert external_usb is not None
if "device" in self.entity_description.key:
for device in external_usb.get_devices.values():
if device.device_name == self._device_id:
return super().available
elif "partition" in self.entity_description.key:
for device in external_usb.get_devices.values():
for partition in device.device_partitions.values():
if partition.partition_title == self._device_id:
return super().available
return False
class SynoDSMInfoSensor(SynoDSMSensor):
"""Representation a Synology information sensor."""

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from synology_dsm.exceptions import SynologyDSMException
@@ -27,7 +27,8 @@ async def _service_handler(call: ServiceCall) -> None:
entry: SynologyDSMConfigEntry | None = (
call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
)
assert entry
if TYPE_CHECKING:
assert entry
dsm_device = entry.runtime_data
elif len(dsm_devices) == 1:
dsm_device = next(iter(dsm_devices.values()))

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
@@ -45,7 +45,8 @@ async def async_setup_entry(
"""Set up the Synology NAS switch."""
data = entry.runtime_data
if coordinator := data.coordinator_switches:
assert coordinator.version is not None
if TYPE_CHECKING:
assert coordinator.version is not None
async_add_entities(
SynoDSMSurveillanceHomeModeToggle(
data.api, coordinator.version, coordinator, description
@@ -79,8 +80,9 @@ class SynoDSMSurveillanceHomeModeToggle(
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on Home mode."""
assert self._api.surveillance_station is not None
assert self._api.information
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
assert self._api.information
_LOGGER.debug(
"SynoDSMSurveillanceHomeModeToggle.turn_on(%s)",
self._api.information.serial,
@@ -90,8 +92,9 @@ class SynoDSMSurveillanceHomeModeToggle(
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off Home mode."""
assert self._api.surveillance_station is not None
assert self._api.information
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
assert self._api.information
_LOGGER.debug(
"SynoDSMSurveillanceHomeModeToggle.turn_off(%s)",
self._api.information.serial,
@@ -107,9 +110,10 @@ class SynoDSMSurveillanceHomeModeToggle(
@property
def device_info(self) -> DeviceInfo:
"""Return the device information."""
assert self._api.surveillance_station is not None
assert self._api.information is not None
assert self._api.network is not None
if TYPE_CHECKING:
assert self._api.surveillance_station is not None
assert self._api.information is not None
assert self._api.network is not None
return DeviceInfo(
identifiers={
(

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from typing import TYPE_CHECKING, Final
from synology_dsm.api.core.upgrade import SynoCoreUpgrade
from yarl import URL
@@ -63,13 +63,15 @@ class SynoDSMUpdateEntity(
@property
def installed_version(self) -> str | None:
"""Version installed and in use."""
assert self._api.information is not None
if TYPE_CHECKING:
assert self._api.information is not None
return self._api.information.version_string
@property
def latest_version(self) -> str | None:
"""Latest version available for install."""
assert self._api.upgrade is not None
if TYPE_CHECKING:
assert self._api.upgrade is not None
if not self._api.upgrade.update_available:
return self.installed_version
return self._api.upgrade.available_version
@@ -77,8 +79,9 @@ class SynoDSMUpdateEntity(
@property
def release_url(self) -> str | None:
"""URL to the full release notes of the latest version available."""
assert self._api.information is not None
assert self._api.upgrade is not None
if TYPE_CHECKING:
assert self._api.information is not None
assert self._api.upgrade is not None
if (details := self._api.upgrade.available_version_details) is None:
return None

View File

@@ -1,43 +0,0 @@
"""Analytics platform."""
from homeassistant.components.analytics import (
AnalyticsInput,
AnalyticsModifications,
EntityAnalyticsModifications,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import entity_registry as er
FILTERED_PLATFORM_CAPABILITY: dict[str, str] = {
Platform.FAN: "preset_modes",
Platform.SELECT: "options",
}
async def async_modify_analytics(
hass: HomeAssistant, analytics_input: AnalyticsInput
) -> AnalyticsModifications:
"""Modify the analytics."""
ent_reg = er.async_get(hass)
entities: dict[str, EntityAnalyticsModifications] = {}
for entity_id in analytics_input.entity_ids:
platform = split_entity_id(entity_id)[0]
if platform not in FILTERED_PLATFORM_CAPABILITY:
continue
entity_entry = ent_reg.entities[entity_id]
if entity_entry.capabilities is not None:
filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform]
if filtered_capability not in entity_entry.capabilities:
continue
capabilities = dict(entity_entry.capabilities)
capabilities[filtered_capability] = len(capabilities[filtered_capability])
entities[entity_id] = EntityAnalyticsModifications(
capabilities=capabilities
)
return AnalyticsModifications(entities=entities)

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData
from .util import get_dpcode
@@ -57,12 +57,8 @@ STATE_MAPPING: dict[str, AlarmControlPanelState] = {
}
# All descriptions can be found here:
# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
ALARM: dict[str, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = {
# Alarm Host
# https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf
"mal": (
ALARM: dict[DeviceCategory, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = {
DeviceCategory.MAL: (
TuyaAlarmControlPanelEntityDescription(
key=DPCode.MASTER_MODE,
master_state=DPCode.MASTER_STATE,
@@ -79,23 +75,23 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya alarm dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya siren."""
entities: list[TuyaAlarmEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
TuyaAlarmEntity(device, hass_data.manager, description)
TuyaAlarmEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
@@ -48,11 +48,8 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription(
# All descriptions can be found here. Mostly the Boolean data types in the
# default status set of each category (that don't have a set instruction)
# end up being a binary sensor.
# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
# CO2 Detector
# https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
"co2bj": (
BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ...]] = {
DeviceCategory.CO2BJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CO2_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -60,9 +57,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# CO Detector
# https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
"cobj": (
DeviceCategory.COBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CO_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -75,9 +70,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Dehumidifier
# https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
"cs": (
DeviceCategory.CS: (
TuyaBinarySensorEntityDescription(
key="tankfull",
dpcode=DPCode.FAULT,
@@ -103,18 +96,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
translation_key="wet",
),
),
# Smart Pet Feeder
# https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
"cwwsq": (
DeviceCategory.CWWSQ: (
TuyaBinarySensorEntityDescription(
key=DPCode.FEED_STATE,
translation_key="feeding",
on_value="feeding",
),
),
# Multi-functional Sensor
# https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
"dgnbj": (
DeviceCategory.DGNBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.GAS_SENSOR_STATE,
device_class=BinarySensorDeviceClass.GAS,
@@ -177,18 +166,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Human Presence Sensor
# https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs
"hps": (
DeviceCategory.HPS: (
TuyaBinarySensorEntityDescription(
key=DPCode.PRESENCE_STATE,
device_class=BinarySensorDeviceClass.OCCUPANCY,
on_value={"presence", "small_move", "large_move", "peaceful"},
),
),
# Formaldehyde Detector
# Note: Not documented
"jqbj": (
DeviceCategory.JQBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CH2O_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -196,9 +181,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Methane Detector
# https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm
"jwbj": (
DeviceCategory.JWBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CH4_SENSOR_STATE,
device_class=BinarySensorDeviceClass.GAS,
@@ -206,9 +189,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Luminance Sensor
# https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
"ldcg": (
DeviceCategory.LDCG: (
TuyaBinarySensorEntityDescription(
key=DPCode.TEMPER_ALARM,
device_class=BinarySensorDeviceClass.TAMPER,
@@ -216,18 +197,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Door and Window Controller
# https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9
"mc": (
DeviceCategory.MC: (
TuyaBinarySensorEntityDescription(
key=DPCode.STATUS,
device_class=BinarySensorDeviceClass.DOOR,
on_value={"open", "opened"},
),
),
# Door Window Sensor
# https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m
"mcs": (
DeviceCategory.MCS: (
TuyaBinarySensorEntityDescription(
key=DPCode.DOORCONTACT_STATE,
device_class=BinarySensorDeviceClass.DOOR,
@@ -238,18 +215,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Access Control
# https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet
"mk": (
DeviceCategory.MK: (
TuyaBinarySensorEntityDescription(
key=DPCode.CLOSED_OPENED_KIT,
device_class=BinarySensorDeviceClass.LOCK,
on_value={"AQAB"},
),
),
# PIR Detector
# https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80
"pir": (
DeviceCategory.PIR: (
TuyaBinarySensorEntityDescription(
key=DPCode.PIR,
device_class=BinarySensorDeviceClass.MOTION,
@@ -257,9 +230,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# PM2.5 Sensor
# https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu
"pm2.5": (
DeviceCategory.PM2_5: (
TuyaBinarySensorEntityDescription(
key=DPCode.PM25_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -267,12 +238,8 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Temperature and Humidity Sensor with External Probe
# New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
"qxj": (TAMPER_BINARY_SENSOR,),
# Gas Detector
# https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw
"rqbj": (
DeviceCategory.QXJ: (TAMPER_BINARY_SENSOR,),
DeviceCategory.RQBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.GAS_SENSOR_STATUS,
device_class=BinarySensorDeviceClass.GAS,
@@ -285,18 +252,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Siren Alarm
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sgbj": (
DeviceCategory.SGBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.CHARGE_STATE,
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
),
TAMPER_BINARY_SENSOR,
),
# Water Detector
# https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
"sj": (
DeviceCategory.SJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.WATERSENSOR_STATE,
device_class=BinarySensorDeviceClass.MOISTURE,
@@ -304,18 +267,14 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Emergency Button
# https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy
"sos": (
DeviceCategory.SOS: (
TuyaBinarySensorEntityDescription(
key=DPCode.SOS_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
),
TAMPER_BINARY_SENSOR,
),
# Volatile Organic Compound Sensor
# Note: Undocumented in cloud API docs, based on test device
"voc": (
DeviceCategory.VOC: (
TuyaBinarySensorEntityDescription(
key=DPCode.VOC_STATE,
device_class=BinarySensorDeviceClass.SAFETY,
@@ -323,9 +282,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Gateway control
# https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok
"wg2": (
DeviceCategory.WG2: (
TuyaBinarySensorEntityDescription(
key=DPCode.MASTER_STATE,
device_class=BinarySensorDeviceClass.PROBLEM,
@@ -333,39 +290,29 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
on_value="alarm",
),
),
# Thermostat
# https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
"wk": (
DeviceCategory.WK: (
TuyaBinarySensorEntityDescription(
key=DPCode.VALVE_STATE,
translation_key="valve",
on_value="open",
),
),
# Thermostatic Radiator Valve
# Not documented
"wkf": (
DeviceCategory.WKF: (
TuyaBinarySensorEntityDescription(
key=DPCode.WINDOW_STATE,
device_class=BinarySensorDeviceClass.WINDOW,
on_value="opened",
),
),
# Temperature and Humidity Sensor
# https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
"wsdcg": (TAMPER_BINARY_SENSOR,),
# Pressure Sensor
# https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
"ylcg": (
DeviceCategory.WSDCG: (TAMPER_BINARY_SENSOR,),
DeviceCategory.YLCG: (
TuyaBinarySensorEntityDescription(
key=DPCode.PRESSURE_STATE,
on_value="alarm",
),
TAMPER_BINARY_SENSOR,
),
# Smoke Detector
# https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
"ywbj": (
DeviceCategory.YWBJ: (
TuyaBinarySensorEntityDescription(
key=DPCode.SMOKE_SENSOR_STATUS,
device_class=BinarySensorDeviceClass.SMOKE,
@@ -378,9 +325,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
),
TAMPER_BINARY_SENSOR,
),
# Vibration Sensor
# https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
"zd": (
DeviceCategory.ZD: (
TuyaBinarySensorEntityDescription(
key=f"{DPCode.SHOCK_STATE}_vibration",
dpcode=DPCode.SHOCK_STATE,
@@ -425,14 +370,14 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya binary sensor dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya binary sensor."""
entities: list[TuyaBinarySensorEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if descriptions := BINARY_SENSORS.get(device.category):
for description in descriptions:
dpcode = description.dpcode or description.key
@@ -448,7 +393,7 @@ async def async_setup_entry(
entities.append(
TuyaBinarySensorEntity(
device,
hass_data.manager,
manager,
description,
mask,
)
@@ -456,7 +401,7 @@ async def async_setup_entry(
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -11,23 +11,17 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
# All descriptions can be found here.
# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = {
# Wake Up Light II
# Not documented
"hxd": (
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
DeviceCategory.HXD: (
ButtonEntityDescription(
key=DPCode.SWITCH_USB6,
translation_key="snooze",
),
),
# Robot Vacuum
# https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
"sd": (
DeviceCategory.SD: (
ButtonEntityDescription(
key=DPCode.RESET_DUSTER_CLOTH,
translation_key="reset_duster_cloth",
@@ -63,24 +57,24 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya buttons dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya buttons."""
entities: list[TuyaButtonEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if descriptions := BUTTONS.get(device.category):
entities.extend(
TuyaButtonEntity(device, hass_data.manager, description)
TuyaButtonEntity(device, manager, description)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -11,18 +11,12 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
# All descriptions can be found here:
# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
CAMERAS: tuple[str, ...] = (
# Smart Camera - Low power consumption camera
# Undocumented, see https://github.com/home-assistant/core/issues/132844
"dghsxj",
# Smart Camera (including doorbells)
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sp",
CAMERAS: tuple[DeviceCategory, ...] = (
DeviceCategory.DGHSXJ,
DeviceCategory.SP,
)
@@ -32,20 +26,20 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cameras dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya camera."""
entities: list[TuyaCameraEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if device.category in CAMERAS:
entities.append(TuyaCameraEntity(device, hass_data.manager))
entities.append(TuyaCameraEntity(device, manager))
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -24,7 +24,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData
from .util import get_dpcode
@@ -48,40 +48,28 @@ class TuyaClimateEntityDescription(ClimateEntityDescription):
switch_only_hvac_mode: HVACMode
CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = {
# Electric Fireplace
# https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop
"dbl": TuyaClimateEntityDescription(
CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = {
DeviceCategory.DBL: TuyaClimateEntityDescription(
key="dbl",
switch_only_hvac_mode=HVACMode.HEAT,
),
# Air conditioner
# https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
"kt": TuyaClimateEntityDescription(
DeviceCategory.KT: TuyaClimateEntityDescription(
key="kt",
switch_only_hvac_mode=HVACMode.COOL,
),
# Heater
# https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82
"qn": TuyaClimateEntityDescription(
DeviceCategory.QN: TuyaClimateEntityDescription(
key="qn",
switch_only_hvac_mode=HVACMode.HEAT,
),
# Heater
# https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx
"rs": TuyaClimateEntityDescription(
DeviceCategory.RS: TuyaClimateEntityDescription(
key="rs",
switch_only_hvac_mode=HVACMode.HEAT,
),
# Thermostat
# https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
"wk": TuyaClimateEntityDescription(
DeviceCategory.WK: TuyaClimateEntityDescription(
key="wk",
switch_only_hvac_mode=HVACMode.HEAT_COOL,
),
# Thermostatic Radiator Valve
# Not documented
"wkf": TuyaClimateEntityDescription(
DeviceCategory.WKF: TuyaClimateEntityDescription(
key="wkf",
switch_only_hvac_mode=HVACMode.HEAT,
),
@@ -94,26 +82,26 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya climate dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered Tuya climate."""
entities: list[TuyaClimateEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if device and device.category in CLIMATE_DESCRIPTIONS:
entities.append(
TuyaClimateEntity(
device,
hass_data.manager,
manager,
CLIMATE_DESCRIPTIONS[device.category],
hass.config.units.temperature_unit,
)
)
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -92,6 +92,509 @@ class DPType(StrEnum):
STRING = "String"
class DeviceCategory(StrEnum):
"""Tuya device categories.
https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq
"""
AMY = "amy"
"""Massage chair"""
BGL = "bgl"
"""Wall-hung boiler"""
BH = "bh"
"""Smart kettle
https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7
"""
BX = "bx"
"""Refrigerator"""
BXX = "bxx"
"""Safe box"""
CJKG = "cjkg"
"""Scene switch"""
CKMKZQ = "ckmkzq"
"""Garage door opener
https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
"""
CKQDKG = "ckqdkg"
"""Card switch"""
CL = "cl"
"""Curtain
https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
"""
CLKG = "clkg"
"""Curtain switch
https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
"""
CN = "cn"
"""Milk dispenser"""
CO2BJ = "co2bj"
"""CO2 detector
https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy
"""
COBJ = "cobj"
"""CO detector
https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v
"""
CS = "cs"
"""Dehumidifier
https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha
"""
CWTSWSQ = "cwtswsq"
"""Pet treat feeder"""
CWWQFSQ = "cwwqfsq"
"""Pet ball thrower"""
CWWSQ = "cwwsq"
"""Pet feeder
https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld
"""
CWYSJ = "cwysj"
"""Pet fountain
https://developer.tuya.com/en/docs/iot/categorycwysj?id=Kaiuz2dfro0nd
"""
CZ = "cz"
"""Socket"""
DBL = "dbl"
"""Electric fireplace
https://developer.tuya.com/en/docs/iot/electric-fireplace?id=Kaiuz2hz4iyp6
"""
DC = "dc"
"""String lights
# https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu
"""
DCL = "dcl"
"""Induction cooker"""
DD = "dd"
"""Strip lights
https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l
"""
DGNBJ = "dgnbj"
"""Multi-functional alarm
https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3
"""
DJ = "dj"
"""Light
https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy
"""
DLQ = "dlq"
"""Circuit breaker
https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8
"""
DR = "dr"
"""Electric blanket
https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p
"""
DS = "ds"
"""TV set"""
FS = "fs"
"""Fan
https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c
"""
FSD = "fsd"
"""Ceiling fan light
https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v
"""
FWD = "fwd"
"""Ambiance light
https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g
"""
GGQ = "ggq"
"""Irrigator
https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k
"""
GYD = "gyd"
"""Motion sensor light
https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy
"""
GYMS = "gyms"
"""Business lock"""
HOTELMS = "hotelms"
"""Hotel lock"""
HPS = "hps"
"""Human presence sensor
https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs
"""
JS = "js"
"""Water purifier"""
JSQ = "jsq"
"""Humidifier
https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b
"""
JTMSBH = "jtmsbh"
"""Smart lock (keep alive)"""
JTMSPRO = "jtmspro"
"""Residential lock pro"""
JWBJ = "jwbj"
"""Methane detector
https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm
"""
KFJ = "kfj"
"""Coffee maker
https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f
"""
KG = "kg"
"""Switch
https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
"""
KJ = "kj"
"""Air purifier
https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm
"""
KQZG = "kqzg"
"""Air fryer"""
KT = "kt"
"""Air conditioner
https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n
"""
KTKZQ = "ktkzq"
"""Air conditioner controller"""
LDCG = "ldcg"
"""Luminance sensor
https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
"""
LILIAO = "liliao"
"""Physiotherapy product"""
LYJ = "lyj"
"""Drying rack"""
MAL = "mal"
"""Alarm host
https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf
"""
MB = "mb"
"""Bread maker"""
MC = "mc"
"""Door/window controller
https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9
"""
MCS = "mcs"
"""Contact sensor
https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m
"""
MG = "mg"
"""Rice cabinet"""
MJJ = "mjj"
"""Towel rack"""
MK = "mk"
"""Access control
https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet
"""
MS = "ms"
"""Residential lock"""
MS_CATEGORY = "ms_category"
"""Lock accessories"""
MSP = "msp"
"""Cat toilet
https://developer.tuya.com/en/docs/iot/s?id=Kakg3srr4ora7
"""
MZJ = "mzj"
"""Sous vide cooker
https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux
"""
NNQ = "nnq"
"""Bottle warmer"""
NTQ = "ntq"
"""HVAC"""
PC = "pc"
"""Power strip
https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
"""
PHOTOLOCK = "photolock"
"""Audio and video lock"""
PIR = "pir"
"""Human motion sensor
https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80
"""
PM2_5 = "pm2.5"
"""PM2.5 detector
https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu
"""
QN = "qn"
"""Heater
https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm
"""
RQBJ = "rqbj"
"""Gas alarm
https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw
"""
RS = "rs"
"""Water heater
https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx
"""
SB = "sb"
"""Watch/band"""
SD = "sd"
"""Robot vacuum
https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo
"""
SF = "sf"
"""Sofa"""
SGBJ = "sgbj"
"""Siren alarm
https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"""
SJ = "sj"
"""Water leak detector
https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli
"""
SOS = "sos"
"""Emergency button
https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy
"""
SP = "sp"
"""Smart camera
https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12
"""
SZ = "sz"
"""Smart indoor garden
https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0
"""
TGKG = "tgkg"
"""Dimmer switch
https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
"""
TGQ = "tgq"
"""Dimmer
https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o
"""
TNQ = "tnq"
"""Smart milk kettle"""
TRACKER = "tracker"
"""Tracker"""
TS = "ts"
"""Smart jump rope"""
TYNDJ = "tyndj"
"""Solar light
https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
"""
TYY = "tyy"
"""Projector"""
TZC1 = "tzc1"
"""Body fat scale"""
VIDEOLOCK = "videolock"
"""Lock with camera"""
WK = "wk"
"""Thermostat
https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9
"""
WSDCG = "wsdcg"
"""Temperature and humidity sensor
https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
"""
XDD = "xdd"
"""Ceiling light
https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r
"""
XFJ = "xfj"
"""Ventilation system"""
XXJ = "xxj"
"""Diffuser
https://developer.tuya.com/en/docs/iot/categoryxxj?id=Kaiuz1f9mo6bl
"""
XY = "xy"
"""Washing machine"""
YB = "yb"
"""Bathroom heater"""
YG = "yg"
"""Bathtub"""
YKQ = "ykq"
"""Remote control
https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov
"""
YLCG = "ylcg"
"""Pressure sensor
https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
"""
YWBJ = "ywbj"
"""Smoke alarm
https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952
"""
ZD = "zd"
"""Vibration sensor
https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno
"""
ZNDB = "zndb"
"""Smart electricity meter
https://developer.tuya.com/en/docs/iot/smart-meter?id=Kaiuz4gv6ack7
"""
ZNFH = "znfh"
"""Bento box"""
ZNSB = "znsb"
"""Smart water meter"""
ZNYH = "znyh"
"""Smart pill box"""
# Undocumented
AQCZ = "aqcz"
"""Single Phase power meter (undocumented)"""
BZYD = "bzyd"
"""White noise machine (undocumented)"""
CWJWQ = "cwjwq"
"""Smart Odor Eliminator-Pro (undocumented)
see https://github.com/orgs/home-assistant/discussions/79
"""
DGHSXJ = "dghsxj"
"""Smart Camera - Low power consumption camera (undocumented)
see https://github.com/home-assistant/core/issues/132844
"""
DSD = "dsd"
"""Filament Light
Based on data from https://github.com/home-assistant/core/issues/106703
Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6
As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc
"""
FSKG = "fskg"
"""Fan wall switch (undocumented)"""
HJJCY = "hjjcy"
"""Air Quality Monitor
https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv
"""
HXD = "hxd"
"""Wake Up Light II (undocumented)"""
JDCLJQR = "jdcljqr"
"""Curtain Robot (undocumented)"""
JQBJ = "jqbj"
"""Formaldehyde Detector (undocumented)"""
KS = "ks"
"""Tower fan (undocumented)
See https://github.com/orgs/home-assistant/discussions/329
"""
MBD = "mbd"
"""Unknown light product
Found as VECINO RGBW as provided by diagnostics
"""
QCCDZ = "qccdz"
"""AC charging (undocumented)"""
QJDCZ = "qjdcz"
""" Unknown product with light capabilities
Found in some diffusers, plugs and PIR flood lights
"""
QXJ = "qxj"
"""Temperature and Humidity Sensor with External Probe (undocumented)
see https://github.com/home-assistant/core/issues/136472
"""
SFKZQ = "sfkzq"
"""Smart Water Timer (undocumented)"""
SJZ = "sjz"
"""Electric desk (undocumented)"""
SZJCY = "szjcy"
"""Water tester (undocumented)"""
SZJQR = "szjqr"
"""Fingerbot (undocumented)"""
SWTZ = "swtz"
"""Cooking thermometer (undocumented)"""
TDQ = "tdq"
"""Dimmer (undocumented)"""
TYD = "tyd"
"""Outdoor flood light (undocumented)"""
VOC = "voc"
"""Volatile Organic Compound Sensor (undocumented)"""
WG2 = "wg2" # Documented, but not in official list
"""Gateway control
https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok
"""
WKCZ = "wkcz"
"""Two-way temperature and humidity switch (undocumented)
"MOES Temperature and Humidity Smart Switch Module MS-103"
"""
WKF = "wkf"
"""Thermostatic Radiator Valve (undocumented)"""
WNYKQ = "wnykq"
"""Smart WiFi IR Remote (undocumented)
eMylo Smart WiFi IR Remote
Air Conditioner Mate (Smart IR Socket)
"""
WXKG = "wxkg" # Documented, but not in official list
"""Wireless Switch
https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp
"""
XNYJCN = "xnyjcn"
"""Micro Storage Inverter
Energy storage and solar PV inverter system with monitoring capabilities
"""
YWCGQ = "ywcgq"
"""Tank Level Sensor (undocumented)"""
ZNNBQ = "znnbq"
"""VESKA-micro inverter (undocumented)"""
ZWJCY = "zwjcy"
"""Soil sensor - plant monitor (undocumented)"""
ZNJXS = "znjxs"
"""Hejhome whitelabel Fingerbot (undocumented)"""
ZNRB = "znrb"
"""Pool HeatPump (undocumented)"""
class DPCode(StrEnum):
"""Data Point Codes used by Tuya.

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData
from .util import get_dpcode
@@ -40,10 +40,8 @@ class TuyaCoverEntityDescription(CoverEntityDescription):
motor_reverse_mode: DPCode | None = None
COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
# Garage Door Opener
# https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee
"ckmkzq": (
COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
DeviceCategory.CKMKZQ: (
TuyaCoverEntityDescription(
key=DPCode.SWITCH_1,
translation_key="indexed_door",
@@ -69,10 +67,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
device_class=CoverDeviceClass.GARAGE,
),
),
# Curtain
# Note: Multiple curtains isn't documented
# https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df
"cl": (
DeviceCategory.CL: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
@@ -117,9 +112,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
device_class=CoverDeviceClass.BLIND,
),
),
# Curtain Switch
# https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39
"clkg": (
DeviceCategory.CLKG: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
@@ -138,9 +131,7 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
device_class=CoverDeviceClass.CURTAIN,
),
),
# Curtain Robot
# Note: Not documented
"jdcljqr": (
DeviceCategory.JDCLJQR: (
TuyaCoverEntityDescription(
key=DPCode.CONTROL,
translation_key="curtain",
@@ -158,17 +149,17 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cover dynamically through Tuya discovery."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
@callback
def async_discover_device(device_ids: list[str]) -> None:
"""Discover and add a discovered tuya cover."""
entities: list[TuyaCoverEntity] = []
for device_id in device_ids:
device = hass_data.manager.device_map[device_id]
device = manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
entities.extend(
TuyaCoverEntity(device, hass_data.manager, description)
TuyaCoverEntity(device, manager, description)
for description in descriptions
if (
description.key in device.function
@@ -178,7 +169,7 @@ async def async_setup_entry(
async_add_entities(entities)
async_discover_device([*hass_data.manager.device_map])
async_discover_device([*manager.device_map])
entry.async_on_unload(
async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device)

View File

@@ -39,15 +39,15 @@ def _async_get_diagnostics(
device: DeviceEntry | None = None,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
hass_data = entry.runtime_data
manager = entry.runtime_data.manager
mqtt_connected = None
if hass_data.manager.mq.client:
mqtt_connected = hass_data.manager.mq.client.is_connected()
if manager.mq.client:
mqtt_connected = manager.mq.client.is_connected()
data = {
"endpoint": hass_data.manager.customer_api.endpoint,
"terminal_id": hass_data.manager.terminal_id,
"endpoint": manager.customer_api.endpoint,
"terminal_id": manager.terminal_id,
"mqtt_connected": mqtt_connected,
"disabled_by": entry.disabled_by,
"disabled_polling": entry.pref_disable_polling,
@@ -55,14 +55,12 @@ def _async_get_diagnostics(
if device:
tuya_device_id = next(iter(device.identifiers))[1]
data |= _async_device_as_dict(
hass, hass_data.manager.device_map[tuya_device_id]
)
data |= _async_device_as_dict(hass, manager.device_map[tuya_device_id])
else:
data.update(
devices=[
_async_device_as_dict(hass, device)
for device in hass_data.manager.device_map.values()
for device in manager.device_map.values()
]
)

Some files were not shown because too many files have changed in this diff Show More