Initial Home Assistant commit
This commit is contained in:
@@ -0,0 +1,764 @@
|
||||
"""
|
||||
Alexa Devices Entities.
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
from alexapy import AlexaAPI, AlexaLogin
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .helpers import safe_get
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long we keep "requested state" protected from stale coordinator values
|
||||
# when capabilityStates do not include a usable timeOfSample.
|
||||
_REQUESTED_STATE_TTL = timedelta(seconds=15)
|
||||
|
||||
|
||||
def has_capability(
|
||||
appliance: dict[str, Any], interface_name: str, property_name: str
|
||||
) -> bool:
|
||||
"""Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant.
|
||||
|
||||
Args:
|
||||
appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details
|
||||
interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API
|
||||
property_name(str): The property that matches the interface name.
|
||||
|
||||
"""
|
||||
for cap in appliance["capabilities"]:
|
||||
props = cap.get("properties")
|
||||
if (
|
||||
cap["interfaceName"] == interface_name
|
||||
and props
|
||||
and (props["retrievable"] or props["proactivelyReported"])
|
||||
):
|
||||
for prop in props["supported"]:
|
||||
if prop["name"] == property_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_hue_v1(appliance: dict[str, Any]) -> bool:
|
||||
"""Determine if an appliance is managed via the Philips Hue v1 Hub.
|
||||
|
||||
This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older
|
||||
Philips Hue bulbs and hubs. This includes things exposed by HA to Alexa using the emulated_hue integration.
|
||||
"""
|
||||
return appliance.get("manufacturerName") == "Royal Philips Electronics"
|
||||
|
||||
|
||||
def is_skill(appliance: dict[str, Any]) -> bool:
|
||||
namespace = safe_get(appliance, ["driverIdentity", "namespace"], "")
|
||||
return namespace and namespace == "SKILL"
|
||||
|
||||
|
||||
def is_known_ha_bridge(appliance: dict[str, Any] | None) -> bool:
|
||||
"""Test whether a bridge appliance is a known HA bridge to avoid creating loops."""
|
||||
|
||||
if appliance is None:
|
||||
return False
|
||||
|
||||
if appliance.get("manufacturerName") in ("t0bst4r", "Matterbridge"):
|
||||
return True
|
||||
|
||||
# Identify Matter bridge hubs regardless of manufacturerName
|
||||
if "HUB" in appliance.get("applianceTypes", []):
|
||||
driver_ns = safe_get(appliance, ["driverIdentity", "namespace"], "")
|
||||
driver_id = safe_get(appliance, ["driverIdentity", "identifier"], "")
|
||||
if driver_ns == "AAA" and driver_id == "SonarCloudService":
|
||||
interfaces = {
|
||||
cap.get("interfaceName") for cap in appliance.get("capabilities", [])
|
||||
}
|
||||
if (
|
||||
"Alexa.Matter.NodeOperationalCredentials.FabricManagement" in interfaces
|
||||
or "Alexa.Commissionable" in interfaces
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_local(appliance: dict[str, Any]) -> bool:
|
||||
"""Test whether locally connected.
|
||||
|
||||
This is mainly present to prevent loops with the official Alexa integration.
|
||||
There is probably a better way to prevent that, but this works.
|
||||
"""
|
||||
|
||||
if appliance.get("connectedVia"):
|
||||
# connectedVia is a flag that determines which Echo devices holds the connection. Its blank for
|
||||
# skill derived devices and includes an Echo name for zigbee and local devices.
|
||||
return True
|
||||
|
||||
# This catches the Echo/AVS devices. connectedVia isn't reliable in this case.
|
||||
# Only the first appears to get that set.
|
||||
if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []):
|
||||
return not is_skill(appliance)
|
||||
|
||||
# Ledvance/Sengled bulbs connected via bluetooth are hard to detect as locally connected
|
||||
# Amazon devices are not local but bypassing the local check allows for control by the integration
|
||||
# There is probably a better way, but this works for now.
|
||||
manufacturerNames = ["Ledvance", "Sengled", "Amazon"]
|
||||
if appliance.get("manufacturerName") in manufacturerNames:
|
||||
return not is_skill(appliance)
|
||||
|
||||
# Zigbee devices are guaranteed to be local and have a particular pattern of id
|
||||
zigbee_pattern = re.compile(
|
||||
"AAA_SonarCloudService_([0-9A-F][0-9A-F]:){7}[0-9A-F][0-9A-F]", flags=re.I
|
||||
)
|
||||
return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None
|
||||
|
||||
|
||||
def is_alexa_guard(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the guard alarm system of an echo."""
|
||||
return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability(
|
||||
appliance, "Alexa.SecurityPanelController", "armState"
|
||||
)
|
||||
|
||||
|
||||
def is_temperature_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the temperature sensor of an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and has_capability(appliance, "Alexa.TemperatureSensor", "temperature")
|
||||
and appliance["friendlyDescription"] != "Amazon Indoor Air Quality Monitor"
|
||||
)
|
||||
|
||||
|
||||
# Checks if air quality sensor
|
||||
def is_air_quality_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance the Amazon Indoor Air Quality Monitor (AIAQM)."""
|
||||
return (
|
||||
appliance.get("friendlyDescription") == "Amazon Indoor Air Quality Monitor"
|
||||
and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", [])
|
||||
and has_capability(appliance, "Alexa.RangeController", "rangeValue")
|
||||
)
|
||||
|
||||
|
||||
def is_light(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a light controlled locally by an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and (
|
||||
"LIGHT" in appliance.get("applianceTypes", [])
|
||||
or (
|
||||
"SMARTPLUG" in appliance.get("applianceTypes", [])
|
||||
and appliance.get("customerDefinedDeviceType") == "LIGHT"
|
||||
)
|
||||
)
|
||||
and has_capability(appliance, "Alexa.PowerController", "powerState")
|
||||
)
|
||||
|
||||
|
||||
def is_contact_sensor(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a contact sensor controlled locally by an Echo."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and "CONTACT_SENSOR" in appliance.get("applianceTypes", [])
|
||||
and has_capability(appliance, "Alexa.ContactSensor", "detectionState")
|
||||
)
|
||||
|
||||
|
||||
def is_switch(appliance: dict[str, Any]) -> bool:
|
||||
"""Is the given appliance a switch controlled locally by an Echo, which is not redeclared as a light."""
|
||||
return (
|
||||
is_local(appliance)
|
||||
and (
|
||||
"SMARTPLUG" in appliance.get("applianceTypes", [])
|
||||
or "SWITCH" in appliance.get("applianceTypes", [])
|
||||
)
|
||||
and appliance.get("customerDefinedDeviceType") != "LIGHT"
|
||||
and has_capability(appliance, "Alexa.PowerController", "powerState")
|
||||
)
|
||||
|
||||
|
||||
def get_friendliest_name(appliance: dict[str, Any]) -> str:
|
||||
"""Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one."""
|
||||
aliases = appliance.get("aliases", [])
|
||||
for alias in aliases:
|
||||
friendly = alias.get("friendlyName")
|
||||
if friendly:
|
||||
return friendly
|
||||
return appliance["friendlyName"]
|
||||
|
||||
|
||||
def get_device_serial(appliance: dict[str, Any]) -> str | None:
|
||||
"""Find the device serial id if it is present."""
|
||||
alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", [])
|
||||
for alexa_device_id in alexa_device_id_list:
|
||||
if isinstance(alexa_device_id, dict):
|
||||
return alexa_device_id.get("dmsDeviceSerialNumber")
|
||||
return None
|
||||
|
||||
|
||||
def get_device_bridge(
|
||||
appliance: dict[str, Any], appliances: dict[str, dict[str, Any]]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Find the bridge device for an appliance connected through e.g. a Matter bridge."""
|
||||
|
||||
appliance_id = appliance.get("applianceId")
|
||||
if not isinstance(appliance_id, str) or "#" not in appliance_id:
|
||||
return None
|
||||
|
||||
# HA Matter Hub bridged endpoints are identified by applianceId prefixes
|
||||
# of the form AAA_SonarCloudService_<bridgeId>#<childId>.
|
||||
bridge_id, _sep, _child = appliance_id.partition("#")
|
||||
|
||||
if not bridge_id.startswith("AAA_SonarCloudService_"):
|
||||
return None
|
||||
|
||||
bridge = appliances.get(bridge_id)
|
||||
return bridge if isinstance(bridge, dict) else None
|
||||
|
||||
|
||||
AlexaEntityData = dict[str, list["AlexaCapabilityState"]]
|
||||
|
||||
|
||||
class AlexaEntity(TypedDict):
|
||||
"""Class for Alexaentity."""
|
||||
|
||||
id: str
|
||||
appliance_id: str
|
||||
name: str
|
||||
is_hue_v1: bool
|
||||
|
||||
|
||||
class AlexaLightEntity(AlexaEntity):
|
||||
"""Class for AlexaLightEntity."""
|
||||
|
||||
brightness: bool
|
||||
color: bool
|
||||
color_temperature: bool
|
||||
|
||||
|
||||
class AlexaTemperatureEntity(TypedDict, total=False):
|
||||
device_serial: str
|
||||
is_aiaqm: bool
|
||||
|
||||
|
||||
class AlexaAirQualityEntity(AlexaEntity):
|
||||
"""Class for AlexaAirQualityEntity."""
|
||||
|
||||
device_serial: str
|
||||
|
||||
|
||||
class AlexaAIAQMEntity(AlexaEntity):
|
||||
"""Entity-backed "device" representing an Amazon Indoor Air Quality Monitor."""
|
||||
|
||||
device_serial: str
|
||||
sensors: list[dict[str, str]]
|
||||
|
||||
|
||||
class AlexaBinaryEntity(AlexaEntity):
|
||||
"""Class for AlexaBinaryEntity."""
|
||||
|
||||
battery_level: bool
|
||||
|
||||
|
||||
class AlexaEntities(TypedDict):
|
||||
"""Class for Alexa Entities."""
|
||||
|
||||
light: list[AlexaLightEntity]
|
||||
guard: list[AlexaEntity]
|
||||
temperature: list[AlexaTemperatureEntity]
|
||||
air_quality: list[AlexaAirQualityEntity]
|
||||
aiaqm: list[AlexaAIAQMEntity]
|
||||
binary_sensor: list[AlexaBinaryEntity]
|
||||
smart_switch: list[AlexaEntity]
|
||||
|
||||
|
||||
class AlexaCapabilityState(TypedDict, total=False):
|
||||
"""Class for AlexaCapabilityState."""
|
||||
|
||||
name: str
|
||||
namespace: str
|
||||
value: int | float | str | dict[str, Any]
|
||||
instance: str
|
||||
timeOfSample: str
|
||||
uncertaintyInMilliseconds: int
|
||||
|
||||
|
||||
def parse_alexa_entities(
|
||||
network_details: list[dict[str, Any]] | None,
|
||||
debug: bool = False,
|
||||
) -> AlexaEntities:
|
||||
# pylint: disable=too-many-locals
|
||||
"""Turn the network details into a list of useful entities with the important details extracted."""
|
||||
temperature_sensors: list[AlexaTemperatureEntity] = []
|
||||
air_quality_sensors: list[AlexaAirQualityEntity] = []
|
||||
aiaqm_entities: list[AlexaAIAQMEntity] = []
|
||||
contact_sensors: list[AlexaBinaryEntity] = []
|
||||
switches: list[AlexaEntity] = []
|
||||
guards: list[AlexaEntity] = []
|
||||
lights: list[AlexaLightEntity] = []
|
||||
|
||||
function_name = "parse_alexa_entities()"
|
||||
|
||||
if not network_details:
|
||||
return {
|
||||
"light": lights,
|
||||
"guard": guards,
|
||||
"temperature": temperature_sensors,
|
||||
"air_quality": air_quality_sensors,
|
||||
"aiaqm": aiaqm_entities,
|
||||
"binary_sensor": contact_sensors,
|
||||
"smart_switch": switches,
|
||||
}
|
||||
|
||||
network_dict: dict[str, dict[str, Any]] = {}
|
||||
if debug:
|
||||
_LOGGER.debug("Processing network_details")
|
||||
|
||||
# Build an applianceId → appliance map first so bridged devices
|
||||
# can resolve their bridge regardless of list ordering.
|
||||
for appliance in network_details:
|
||||
appliance_id = appliance.get("applianceId")
|
||||
if appliance_id:
|
||||
network_dict[appliance_id] = appliance
|
||||
|
||||
for appliance in network_details:
|
||||
device_bridge = get_device_bridge(appliance, network_dict)
|
||||
|
||||
bridge_label = (
|
||||
device_bridge.get("friendlyName") or device_bridge.get("manufacturerName")
|
||||
if device_bridge
|
||||
else None
|
||||
)
|
||||
|
||||
appliance_id = str(appliance.get("applianceId", ""))
|
||||
|
||||
# Only log a bridge check when:
|
||||
# - we found a bridge, OR
|
||||
# - ADV debug is enabled AND the appliance looks like a bridge candidate
|
||||
if bridge_label is not None or (debug and "#" in appliance_id):
|
||||
_LOGGER.debug(
|
||||
"%s: Checking device bridge: %s",
|
||||
appliance.get("friendlyName"),
|
||||
bridge_label or "<none>",
|
||||
)
|
||||
|
||||
# ADV-only: only log resolution for cases where it might apply
|
||||
if debug and "#" in appliance_id:
|
||||
bridge_id = device_bridge.get("applianceId") if device_bridge else None
|
||||
_LOGGER.debug(
|
||||
"[%s] [ADV] Matter bridge resolution: appliance=%s → bridge=%s (connectedVia=%s, bridge=%s)",
|
||||
function_name,
|
||||
appliance_id,
|
||||
bridge_id,
|
||||
appliance.get("connectedVia"),
|
||||
bridge_label,
|
||||
)
|
||||
|
||||
if is_known_ha_bridge(device_bridge):
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
'[%s] [ADV] Skipping bridged Matter device "%s" (%s) via known bridge: %s (%s)',
|
||||
function_name,
|
||||
appliance.get("friendlyName"),
|
||||
appliance.get("applianceId"),
|
||||
bridge_label,
|
||||
device_bridge.get("applianceId") if device_bridge else None,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
'Skipping bridged Matter device "%s" via known bridge "%s"',
|
||||
appliance.get("friendlyName"),
|
||||
bridge_label or "<unknown>",
|
||||
)
|
||||
continue
|
||||
|
||||
processed_appliance: AlexaEntity = {
|
||||
"id": appliance["entityId"],
|
||||
"appliance_id": appliance["applianceId"],
|
||||
"name": get_friendliest_name(appliance),
|
||||
"is_hue_v1": is_hue_v1(appliance),
|
||||
}
|
||||
|
||||
if is_alexa_guard(appliance):
|
||||
_LOGGER.debug("Added Alexa Guard: %s", processed_appliance["name"])
|
||||
guards.append(processed_appliance)
|
||||
|
||||
elif is_temperature_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Added temperature sensor: %s", processed_appliance["name"]
|
||||
)
|
||||
serial = get_device_serial(appliance)
|
||||
temp_entity: AlexaTemperatureEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": serial if serial else appliance["entityId"],
|
||||
}
|
||||
temperature_sensors.append(temp_entity)
|
||||
|
||||
elif is_air_quality_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added AIAQM sensor: %s", processed_appliance["name"])
|
||||
|
||||
serial = get_device_serial(appliance)
|
||||
device_serial = serial if serial else appliance["entityId"]
|
||||
|
||||
# Build a list of sub-sensors we can read via AlexaAPI.get_entity_state.
|
||||
# AIAQM metrics are exposed via Alexa.RangeController(rangeValue) with an
|
||||
# instance per metric. Some accounts/devices use numeric instances, so
|
||||
# we derive the sensor type from the friendlyName assetId/text.
|
||||
sensors: list[dict[str, str]] = []
|
||||
for cap in appliance.get("capabilities", []):
|
||||
if cap.get("interfaceName") != "Alexa.RangeController":
|
||||
continue
|
||||
|
||||
# Must support numeric rangeValue to be a sensor.
|
||||
supported = safe_get(cap, ["properties", "supported"], [])
|
||||
if not isinstance(supported, list) or not any(
|
||||
isinstance(p, dict) and p.get("name") == "rangeValue"
|
||||
for p in supported
|
||||
):
|
||||
continue
|
||||
|
||||
instance = cap.get("instance")
|
||||
if instance is None or instance == "":
|
||||
continue
|
||||
if not isinstance(instance, str):
|
||||
if isinstance(instance, (int, float)):
|
||||
instance = str(instance)
|
||||
else:
|
||||
continue
|
||||
|
||||
unit = safe_get(cap, ["configuration", "unitOfMeasure"], "") or ""
|
||||
|
||||
resources = (
|
||||
cap.get("resources", {})
|
||||
if isinstance(cap.get("resources"), dict)
|
||||
else {}
|
||||
)
|
||||
friendly = (
|
||||
resources.get("friendlyNames", [])
|
||||
if isinstance(resources.get("friendlyNames"), list)
|
||||
else []
|
||||
)
|
||||
|
||||
sensor_type: str | None = None
|
||||
for entry in friendly:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
value_obj = entry.get("value")
|
||||
asset_id = None
|
||||
if isinstance(value_obj, dict):
|
||||
asset_id = value_obj.get("assetId")
|
||||
else:
|
||||
asset_id = entry.get("assetId")
|
||||
|
||||
# Only treat Alexa.AirQuality assetIds as real AIAQM sensors.
|
||||
# Text-only friendlyNames (e.g. @type "text") must be ignored to avoid
|
||||
# creating extra sensors such as PM10.
|
||||
if isinstance(asset_id, str) and asset_id.startswith(
|
||||
"Alexa.AirQuality."
|
||||
):
|
||||
sensor_type = asset_id
|
||||
break
|
||||
|
||||
if not sensor_type:
|
||||
continue
|
||||
sensors.append(
|
||||
{
|
||||
"sensorType": str(sensor_type),
|
||||
"instance": instance,
|
||||
"unit": str(unit),
|
||||
}
|
||||
)
|
||||
|
||||
# Always register the AIAQM device (even if no sub-sensors are exposed).
|
||||
aiaqm_entity: AlexaAIAQMEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
"sensors": sensors,
|
||||
}
|
||||
aiaqm_entities.append(aiaqm_entity)
|
||||
|
||||
# Backwards compatibility: also expose as air_quality for existing paths.
|
||||
aq_entity: AlexaAirQualityEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
}
|
||||
air_quality_sensors.append(aq_entity)
|
||||
|
||||
# AIAQM also has temperature; ensure it gets created and grouped with AIAQM.
|
||||
temp_entity: AlexaTemperatureEntity = {
|
||||
**processed_appliance,
|
||||
"device_serial": device_serial,
|
||||
"is_aiaqm": True,
|
||||
}
|
||||
temperature_sensors.append(temp_entity)
|
||||
elif is_switch(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added switch: %s", processed_appliance["name"])
|
||||
switches.append(processed_appliance)
|
||||
|
||||
elif is_light(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added light %s", processed_appliance["name"])
|
||||
processed_appliance["brightness"] = has_capability(
|
||||
appliance, "Alexa.BrightnessController", "brightness"
|
||||
)
|
||||
processed_appliance["color"] = has_capability(
|
||||
appliance, "Alexa.ColorController", "color"
|
||||
)
|
||||
processed_appliance["color_temperature"] = has_capability(
|
||||
appliance,
|
||||
"Alexa.ColorTemperatureController",
|
||||
"colorTemperatureInKelvin",
|
||||
)
|
||||
light_entity: AlexaLightEntity = {
|
||||
**processed_appliance,
|
||||
"brightness": processed_appliance["brightness"],
|
||||
"color": processed_appliance["color"],
|
||||
"color_temperature": processed_appliance["color_temperature"],
|
||||
}
|
||||
lights.append(light_entity)
|
||||
|
||||
elif is_contact_sensor(appliance):
|
||||
if debug:
|
||||
_LOGGER.debug("Added contact sensor: %s", processed_appliance["name"])
|
||||
processed_appliance["battery_level"] = has_capability(
|
||||
appliance, "Alexa.BatteryLevelSensor", "batteryLevel"
|
||||
)
|
||||
binary_entity: AlexaBinaryEntity = {
|
||||
**processed_appliance,
|
||||
"battery_level": processed_appliance["battery_level"],
|
||||
}
|
||||
contact_sensors.append(binary_entity)
|
||||
|
||||
else:
|
||||
if debug:
|
||||
_LOGGER.debug("Unsupported entity: %s", processed_appliance["name"])
|
||||
|
||||
return {
|
||||
"light": lights,
|
||||
"guard": guards,
|
||||
"temperature": temperature_sensors,
|
||||
"air_quality": air_quality_sensors,
|
||||
"aiaqm": aiaqm_entities,
|
||||
"binary_sensor": contact_sensors,
|
||||
"smart_switch": switches,
|
||||
}
|
||||
|
||||
|
||||
async def get_entity_data(
|
||||
login_obj: AlexaLogin, entity_ids: list[str]
|
||||
) -> AlexaEntityData:
|
||||
"""Get and process the entity data into a more usable format."""
|
||||
|
||||
entities = {}
|
||||
if entity_ids:
|
||||
raw = await AlexaAPI.get_entity_state(login_obj, entity_ids=entity_ids)
|
||||
device_states = raw.get("deviceStates", []) if isinstance(raw, dict) else None
|
||||
if device_states:
|
||||
for device_state in device_states:
|
||||
entity_id = safe_get(device_state, ["entity", "entityId"])
|
||||
if entity_id:
|
||||
entities[entity_id] = []
|
||||
cap_states = device_state.get("capabilityStates", [])
|
||||
for cap_state in cap_states:
|
||||
entities[entity_id].append(json.loads(cap_state))
|
||||
return entities
|
||||
|
||||
|
||||
def parse_temperature_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
debug: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the temperature of an entity from the coordinator data."""
|
||||
temperature = parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.TemperatureSensor",
|
||||
"temperature",
|
||||
debug=debug,
|
||||
)
|
||||
if debug:
|
||||
_LOGGER.debug("parse_temperature_from_coordinator: %s", temperature)
|
||||
return temperature
|
||||
|
||||
|
||||
def parse_air_quality_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
instance_id: str,
|
||||
debug: bool = False,
|
||||
) -> int | float | str | None:
|
||||
"""Get the air quality of an entity from the coordinator data."""
|
||||
value = parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.RangeController",
|
||||
"rangeValue",
|
||||
instance=instance_id,
|
||||
debug=debug,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def parse_brightness_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> int | None:
|
||||
"""Get the brightness in the range 0-100."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.BrightnessController",
|
||||
"brightness",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_color_temp_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> int | None:
|
||||
"""Get the color temperature in kelvin."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.ColorTemperatureController",
|
||||
"colorTemperatureInKelvin",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_color_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""Get the color as a tuple of (hue, saturation, brightness)."""
|
||||
value = parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.ColorController", "color", since
|
||||
)
|
||||
if value is not None:
|
||||
hue = value.get("hue", 0)
|
||||
saturation = value.get("saturation", 0)
|
||||
return hue, saturation, 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_power_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
|
||||
) -> str | None:
|
||||
"""Get the power state of the entity."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator,
|
||||
entity_id,
|
||||
"Alexa.PowerController",
|
||||
"powerState",
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def parse_guard_state_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str
|
||||
) -> str | None:
|
||||
"""Get the guard state from the coordinator data."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.SecurityPanelController", "armState"
|
||||
)
|
||||
|
||||
|
||||
def parse_detection_state_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator, entity_id: str
|
||||
) -> bool | None:
|
||||
"""Get the detection state from the coordinator data."""
|
||||
return parse_value_from_coordinator(
|
||||
coordinator, entity_id, "Alexa.ContactSensor", "detectionState"
|
||||
)
|
||||
|
||||
|
||||
def parse_value_from_coordinator(
|
||||
coordinator: DataUpdateCoordinator,
|
||||
entity_id: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
since: datetime | None = None,
|
||||
instance: str | None = None,
|
||||
*,
|
||||
debug: bool = False,
|
||||
) -> Any:
|
||||
"""Parse out values from coordinator for Alexa Entities."""
|
||||
if coordinator.data and entity_id in coordinator.data:
|
||||
found_match = False
|
||||
for cap_state in coordinator.data[entity_id]:
|
||||
cap_instance = cap_state.get("instance")
|
||||
instance_match = instance is None or (
|
||||
cap_instance is not None and str(cap_instance) == str(instance)
|
||||
)
|
||||
if (
|
||||
cap_state.get("namespace") == namespace
|
||||
and cap_state.get("name") == name
|
||||
and instance_match
|
||||
):
|
||||
found_match = True
|
||||
if is_cap_state_still_acceptable(cap_state, since):
|
||||
return cap_state.get("value")
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Coordinator data for %s (%s/%s instance=%s) is too old; checking other matches.",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
# Keep searching in case a newer matching cap_state exists later.
|
||||
continue
|
||||
if debug and found_match:
|
||||
_LOGGER.debug(
|
||||
"No acceptable coordinator data found for %s (%s/%s instance=%s).",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
else:
|
||||
if debug:
|
||||
_LOGGER.debug(
|
||||
"Coordinator has no data yet for %s, %s, %s, %s",
|
||||
entity_id,
|
||||
namespace,
|
||||
name,
|
||||
instance,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def is_cap_state_still_acceptable(
|
||||
cap_state: dict[str, Any], since: datetime | None
|
||||
) -> bool:
|
||||
"""Determine if a particular capability state is still usable given its age."""
|
||||
if since is None:
|
||||
return True
|
||||
|
||||
# Don't protect requested state forever; after TTL fall back to coordinator
|
||||
# even if timeOfSample is missing/unparsable.
|
||||
if datetime.now(timezone.utc) - since > _REQUESTED_STATE_TTL:
|
||||
return True
|
||||
|
||||
formatted_time_of_sample = cap_state.get("timeOfSample")
|
||||
if not formatted_time_of_sample:
|
||||
# If we can't prove the sample is newer than the requested state,
|
||||
# do not allow it to override optimistic/requested values.
|
||||
return False
|
||||
|
||||
try:
|
||||
time_of_sample = datetime.fromisoformat(formatted_time_of_sample)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return time_of_sample >= since
|
||||
Reference in New Issue
Block a user