""" Alexa Devices Sensors. 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 """ import datetime import logging from typing import Callable, ClassVar, Optional from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorStateClass, ) from homeassistant.const import UnitOfTemperature, __version__ as HA_VERSION from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady, NoEntitySpecifiedError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from packaging import version from . import ( CONF_EMAIL, CONF_EXCLUDE_DEVICES, CONF_INCLUDE_DEVICES, DATA_ALEXAMEDIA, DOMAIN as ALEXA_DOMAIN, hide_email, hide_serial, ) from .alexa_entity import ( parse_air_quality_from_coordinator, parse_temperature_from_coordinator, ) from .const import ( ALEXA_AIR_QUALITY_DEVICE_CLASS, ALEXA_ICON_CONVERSION, ALEXA_ICON_DEFAULT, ALEXA_UNIT_CONVERSION, CONF_DEBUG, EPOCH_MS_THRESHOLD, RECURRING_DAY, RECURRING_PATTERN, RECURRING_PATTERN_ISO_SET, ) from .helpers import add_devices, alarm_just_dismissed, is_http2_enabled, safe_get _LOGGER = logging.getLogger(__name__) LOCAL_TIMEZONE = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo async def async_setup_platform(hass, config, add_devices_callback, discovery_info=None): # pylint: disable=too-many-locals """Set up the Alexa sensor platform.""" devices: list[AlexaMediaNotificationSensor] = [] SENSOR_TYPES = { # pylint: disable=invalid-name "Alarm": AlarmSensor, "Timer": TimerSensor, "Reminder": ReminderSensor, } account = None if config: account = config.get(CONF_EMAIL) if account is None and discovery_info: account = safe_get(discovery_info, ["config", CONF_EMAIL]) if account is None: raise ConfigEntryNotReady include_filter = config.get(CONF_INCLUDE_DEVICES, []) exclude_filter = config.get(CONF_EXCLUDE_DEVICES, []) debug = bool(config.get(CONF_DEBUG, False)) account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] _LOGGER.debug("%s: Loading sensors", hide_email(account)) if "sensor" not in account_dict["entities"]: hass.data[DATA_ALEXAMEDIA]["accounts"][account]["entities"]["sensor"] = {} for key, device in account_dict["devices"]["media_player"].items(): if key not in account_dict["entities"]["media_player"]: _LOGGER.debug( "%s: Media player %s not loaded yet; delaying load", hide_email(account), hide_serial(key), ) raise ConfigEntryNotReady if key not in (account_dict["entities"]["sensor"]): account_dict["entities"]["sensor"][key] = {} for n_type, class_ in SENSOR_TYPES.items(): notifications = account_dict.get("notifications") or {} key_notifications = notifications.get(key, {}) n_type_dict = key_notifications.get(n_type, {}) if ( n_type in ("Alarm", "Timer") and "TIMERS_AND_ALARMS" in device["capabilities"] ): alexa_client = class_( account_dict["entities"]["media_player"][key], n_type_dict, account, debug=debug, ) elif n_type in ("Reminder") and "REMINDERS" in device["capabilities"]: alexa_client = class_( account_dict["entities"]["media_player"][key], n_type_dict, account, debug=debug, ) else: continue _LOGGER.debug( "%s: Found %s %s sensor (%s) with next: %s", hide_email(account), hide_serial(key), n_type, len(n_type_dict.keys()), alexa_client.state, ) devices.append(alexa_client) account_dict["entities"]["sensor"][key][n_type] = alexa_client else: for alexa_client in account_dict["entities"]["sensor"][key].values(): _LOGGER.debug( "%s: Skipping already added device: %s", hide_email(account), alexa_client, ) temperature_sensors = [] temperature_entities = safe_get(account_dict, ["devices", "temperature"], []) if temperature_entities: temperature_sensors = await create_temperature_sensors( account_dict, temperature_entities, debug=debug ) # AQM Sensors air_quality_sensors = [] aiaqm_entities = safe_get(account_dict, ["devices", "aiaqm"], []) if aiaqm_entities: air_quality_sensors = await create_air_quality_sensors( account_dict, aiaqm_entities, debug=debug ) return await add_devices( hide_email(account), devices + temperature_sensors + air_quality_sensors, add_devices_callback, include_filter, exclude_filter, ) async def async_setup_entry(hass, config_entry, async_add_devices): """Set up the Alexa sensor platform by config_entry.""" return await async_setup_platform( hass, config_entry.data, async_add_devices, discovery_info=None ) async def async_unload_entry(hass, entry) -> bool: """Unload a config entry.""" account = entry.data[CONF_EMAIL] account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account] _LOGGER.debug("Attempting to unload sensors") for key, sensors in list(account_dict["entities"]["sensor"].items()): for sensor_key, device in list(sensors.items()): if isinstance(device, dict): # Air_Quality stores sensors in a nested dict for nested_key, nested_sensor in list(device.items()): _LOGGER.debug("Removing %s", nested_sensor) await nested_sensor.async_remove() device.pop(nested_key, None) sensors.pop(sensor_key, None) continue _LOGGER.debug("Removing %s", device) await device.async_remove() sensors.pop(sensor_key, None) if not sensors: account_dict["entities"]["sensor"].pop(key, None) return True async def create_temperature_sensors( account_dict, temperature_entities, debug: bool = False, ): """Create temperature sensors.""" devices = [] coordinator = account_dict["coordinator"] for temp in temperature_entities: if debug: _LOGGER.debug( "Creating entity %s for a temperature sensor with name %s (%s)", temp["id"], temp["name"], temp, ) serial = temp["device_serial"] # Temperature can be from an Echo OR from an AIAQM endpoint. # If it's AIAQM, attach the sensor to the synthetic AIAQM HA device # so all AIAQM entities group under one HA device. is_aiaqm = bool(temp.get("is_aiaqm")) if is_aiaqm: device_ident = (ALEXA_DOMAIN, serial) aiaqm_device_serial = serial else: device_ident = lookup_device_info(account_dict, serial) aiaqm_device_serial = None sensor = TemperatureSensor( coordinator, temp["id"], temp["name"], device_ident, device_serial=aiaqm_device_serial, debug=debug, ) account_dict["entities"]["sensor"].setdefault(serial, {}) account_dict["entities"]["sensor"][serial]["Temperature"] = sensor devices.append(sensor) return devices async def create_air_quality_sensors( account_dict, air_quality_entities, debug: bool = False ): devices = [] coordinator = account_dict["coordinator"] for temp in air_quality_entities: _LOGGER.debug( "Creating sensors for %s id: %s", temp["name"], temp["id"], ) subsensors = temp.get("sensors") if not isinstance(subsensors, list) or not subsensors: _LOGGER.debug( "Skipping AIAQM %s (%s): no parsed subsensors found", temp.get("name"), temp.get("id"), ) continue last_index = len(subsensors) - 1 seen_sensor_types: set[str] = set() # Each AIAQM has 5 different sensors. for idx, subsensor in enumerate(subsensors): prefix = "└─" if idx == last_index else "├─" sensor_type = subsensor.get("sensorType") instance = subsensor.get("instance") unit = subsensor.get("unit", "") if sensor_type in seen_sensor_types: _LOGGER.debug( "%sSkipping duplicate AQM sensorType %s (instance=%s)", prefix, sensor_type, instance, ) continue if sensor_type: seen_sensor_types.add(sensor_type) if not sensor_type or instance is None: _LOGGER.debug( "%sSkipping AIAQM subsensor missing sensorType/instance: %s", prefix, subsensor, ) continue serial = temp.get("device_serial") if not serial: _LOGGER.debug( "Skipping AIAQM subsensor %s: missing device_serial", temp.get("name"), ) continue device_ident = (ALEXA_DOMAIN, serial) _LOGGER.debug( " %s AQM sensor: %s", prefix, sensor_type.rsplit(".", 1)[-1], ) sensor = AirQualitySensor( coordinator, temp["id"], temp["name"], device_ident, sensor_type, instance, unit, device_serial=serial, debug=debug, ) account_dict["entities"]["sensor"].setdefault(serial, {}) account_dict["entities"]["sensor"][serial].setdefault("Air_Quality", {}) account_dict["entities"]["sensor"][serial]["Air_Quality"][ sensor.unique_id ] = sensor devices.append(sensor) return devices def lookup_device_info(account_dict, device_serial): """Get the device to use for a given Echo based on a given device serial id. This may return nothing as there is no guarantee that a given temperature sensor is actually attached to an Echo. """ for key, mediaplayer in account_dict["entities"]["media_player"].items(): if ( key == device_serial and mediaplayer.device_info and "identifiers" in mediaplayer.device_info ): for ident in mediaplayer.device_info["identifiers"]: return ident return None class TemperatureSensor(SensorEntity, CoordinatorEntity): """A temperature sensor reported by an Echo or an AIAQM endpoint.""" _attr_has_entity_name = True _attr_translation_key = "temperature" def __init__( self, coordinator, entity_id, name, device_ident, *, device_serial: Optional[str] = None, debug: bool = False, ): """Initialize temperature sensor.""" super().__init__(coordinator) self._debug = bool(debug) self.alexa_entity_id = entity_id self._device_name = name # Need to append "+temperature" because the Alexa entityId is for a physical device # and a single physical device can have multiple HA entities self._attr_unique_id = entity_id + "_temperature" self._attr_device_class = SensorDeviceClass.TEMPERATURE self._attr_state_class = SensorStateClass.MEASUREMENT value_and_scale: Optional[dict] = parse_temperature_from_coordinator( coordinator, entity_id, debug=self._debug ) self._attr_native_value = self._get_temperature_value(value_and_scale) self._attr_native_unit_of_measurement = self._get_temperature_scale( value_and_scale ) # Attach to an HA device by identifier: # - Echo: (DOMAIN, serial) # - AIAQM: (DOMAIN, ) if device_ident: # If we were given an AIAQM serial, expose richer device info in HA. if device_serial: self._attr_device_info = dr.DeviceInfo( identifiers={device_ident}, serial_number=device_serial, manufacturer="Amazon", model="Indoor Air Quality Monitor", name=name, ) else: # Echo-attached temp: just bind to the existing HA device by identifier. self._attr_device_info = dr.DeviceInfo( identifiers={device_ident}, ) else: self._attr_device_info = None _LOGGER.debug("Coordinator init: %s Temperature", self._device_name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" value_and_scale = parse_temperature_from_coordinator( self.coordinator, self.alexa_entity_id, debug=self._debug ) self._attr_native_value = self._get_temperature_value(value_and_scale) self._attr_native_unit_of_measurement = self._get_temperature_scale( value_and_scale ) value_str = ( self._attr_native_value if self._attr_native_value is not None else "" ) unit_str = self._attr_native_unit_of_measurement or "" _LOGGER.debug( "Coordinator update: %s Temperature: %s%s", self._device_name, value_str, unit_str, ) super()._handle_coordinator_update() def _get_temperature_value(self, value): if value and "value" in value: if getattr(self, "_debug", False): _LOGGER.debug("TemperatureSensor value: %s", value.get("value")) return value.get("value") return None def _get_temperature_scale(self, value): if value and "scale" in value: if getattr(self, "_debug", False): _LOGGER.debug("TemperatureSensor scale: %s", value.get("scale")) if value.get("scale") == "CELSIUS": return UnitOfTemperature.CELSIUS if value.get("scale") == "FAHRENHEIT": return UnitOfTemperature.FAHRENHEIT if value.get("scale") == "KELVIN": return UnitOfTemperature.KELVIN return None # Mapping from Alexa AirQuality sensor types to translation keys AIR_QUALITY_TRANSLATION_KEYS = { "Alexa.AirQuality.CarbonMonoxide": "air_quality_carbon_monoxide", "Alexa.AirQuality.Humidity": "air_quality_humidity", "Alexa.AirQuality.IndoorAirQuality": "air_quality_indoor_air_quality", "Alexa.AirQuality.ParticulateMatter": "air_quality_particulate_matter", "Alexa.AirQuality.VolatileOrganicCompounds": "air_quality_volatile_organic_compounds", } class AirQualitySensor(SensorEntity, CoordinatorEntity): """An air quality sensor reported by an Amazon indoor air quality monitor.""" _attr_has_entity_name = True def __init__( self, coordinator, entity_id, name, device_ident, sensor_name, instance, unit, *, device_serial: Optional[str] = None, debug: bool = False, ): super().__init__(coordinator) self._debug = bool(debug) self.alexa_entity_id = entity_id self._device_name = name self._sensor_type = sensor_name # Set translation key based on sensor type self._attr_translation_key = AIR_QUALITY_TRANSLATION_KEYS.get( sensor_name, "air_quality" ) # tidy up name for unique_id and logging self._sensor_name = sensor_name.replace("Alexa.AirQuality.", "") self._sensor_name = "".join( " " + char if char.isupper() else char.strip() for char in self._sensor_name ).strip() self._attr_device_class = ALEXA_AIR_QUALITY_DEVICE_CLASS.get(sensor_name) self._attr_state_class = SensorStateClass.MEASUREMENT self._attr_native_value: Optional[int | float | str] = ( parse_air_quality_from_coordinator( coordinator, entity_id, instance, debug=self._debug ) ) self._attr_native_unit_of_measurement: Optional[str] = ( ALEXA_UNIT_CONVERSION.get(unit) ) self._attr_unique_id = ( entity_id + "_" + self._sensor_name.replace(" ", "_").lower() ) self._attr_icon = ALEXA_ICON_CONVERSION.get(sensor_name, ALEXA_ICON_DEFAULT) # Attach to the synthetic AIAQM device so all AQM sensors group under one device. if device_ident: if device_serial: self._attr_device_info = dr.DeviceInfo( identifiers={device_ident}, serial_number=device_serial, manufacturer="Amazon", model="Indoor Air Quality Monitor", name=name, ) else: self._attr_device_info = dr.DeviceInfo( identifiers={device_ident}, ) else: self._attr_device_info = None self._instance = instance _LOGGER.debug("Coordinator init: %s %s", self._device_name, self._sensor_name) @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._attr_native_value = parse_air_quality_from_coordinator( self.coordinator, self.alexa_entity_id, self._instance, debug=self._debug ) value_str = ( self._attr_native_value if self._attr_native_value is not None else "" ) unit_str = self._attr_native_unit_of_measurement or "" fmt = ( "Coordinator update: %s %s: %s%s" if unit_str in ("", "%") else "Coordinator update: %s %s: %s %s" ) _LOGGER.debug( fmt, self._device_name, self._sensor_name, value_str, unit_str, ) super()._handle_coordinator_update() class AlexaMediaNotificationSensor(SensorEntity): """Representation of Alexa Media sensors.""" _attr_has_entity_name = True _attr_native_unit_of_measurement = None _unrecorded_attributes = frozenset({"brief", "sorted_active", "sorted_all"}) # Guard for internal HA API - only override if base class has this property if hasattr(SensorEntity, "_unit_of_measurement_translation_key"): @property def _unit_of_measurement_translation_key(self) -> str | None: """Return None to prevent unit translation lookup before platform registration. This override is necessary because HA tries to resolve unit translation when translation_key is set, but platform_data is None before entity registration. Timestamp sensors have no units, so we can safely return None. """ return None _LABEL_KEY_MAP: ClassVar[dict[str, str]] = { "Alarm": "alarmLabel", "Timer": "timerLabel", "Reminder": "reminderLabel", } def __init__( self, client, n_dict, sensor_property: str, account, name="Next Notification", icon=None, debug: bool = False, ): """Initialize the Alexa sensor device.""" # Class info self._debug = bool(debug) self._attr_device_class = SensorDeviceClass.TIMESTAMP self._attr_state_class = None self._attr_native_value: Optional[datetime.datetime] = None self._attr_unique_id = f"{client.unique_id}_{name}" self._attr_icon = icon self._attr_device_info = { "identifiers": {(ALEXA_DOMAIN, client.unique_id)}, } self._attr_assumed_state = client.assumed_state self._attr_available = client.available self._client = client self._n_dict = n_dict self._sensor_property = sensor_property self._account = account self._type = "" if not self._type else self._type self._all = [] self._active = [] self._next: Optional[dict] = None self._prior_value = None self._timestamp: Optional[datetime.datetime] = None self._tracker: Optional[Callable] = None self._dismissed: Optional[datetime.datetime] = None self._status: Optional[str] = None self._amz_id: Optional[str] = None self._version: Optional[str] = None def _coerce_datetime(self, value) -> Optional[datetime.datetime]: """Best-effort conversion of Alexa datetime-ish values to aware datetime.""" def _ensure_aware(parsed: datetime.datetime) -> datetime.datetime: if parsed.tzinfo is not None and parsed.utcoffset() is not None: return parsed timezone = dt.get_time_zone( self._client._timezone # pylint: disable=protected-access ) return parsed.replace(tzinfo=timezone or LOCAL_TIMEZONE) if isinstance(value, datetime.datetime): return _ensure_aware(value) if value in (None, ""): return None if isinstance(value, (int, float)): ts = value / 1000 if value > EPOCH_MS_THRESHOLD else value return datetime.datetime.fromtimestamp(ts, tz=LOCAL_TIMEZONE) if isinstance(value, str): parsed = dt.parse_datetime(value) if parsed is None: return None return _ensure_aware(parsed) return None def _normalize_alarm_snooze_state(self, value): """Normalize snoozed alarm state before sorting/selecting next. Amazon can report a snoozed alarm without a reliable future ``snoozedToTime``. In that case, do not synthesize ``snoozedToTime`` from the original alarm time; that value can be stale or already expired. Keep the alarm status as SNOOZED and let active filtering preserve it. """ if self._type != "Alarm" or not value: return value next_item = value[1] if not isinstance(next_item, dict): return value if next_item.get("status") != "SNOOZED": return value snoozed_to = self._coerce_datetime(next_item.get("snoozedToTime")) now = dt.now() if snoozed_to and snoozed_to > now: next_item["snoozedToTime"] = snoozed_to next_item[self._sensor_property] = snoozed_to return value # Missing or expired snoozedToTime should not make a SNOOZED alarm # inactive. Drop the unusable snooze timestamp so the status remains the # source of truth, matching the pre-5.15.1 behavior more closely. next_item["snoozedToTime"] = None return value def _is_active_notification(self, item, now): """Return whether a notification should be considered active.""" status = item[1].get("status") if status == "ON": return True # Treat SNOOZED as active even when Amazon omits or returns an expired # snoozedToTime. The notification status is more reliable than the # derived timestamp in current Alexa payloads. if status == "SNOOZED": return True return False def _select_next_alarm(self, now): """Select next alarm, preferring future active alarms over skipped past ones.""" future_active = [] skipped_past = [] for item in self._active: when = self._coerce_datetime(item[1].get(self._sensor_property)) if when is not None and when > now: future_active.append(item) else: skipped_past.append((item, when)) if self._debug and skipped_past: summary = [ { "id": v.get("id"), "status": v.get("status"), self._sensor_property: when, "snoozedToTime": v.get("snoozedToTime"), } for (_, v), when in skipped_past ] _LOGGER.debug( "%s: %s %s skipped past notifications: %s", hide_email(self._account), hide_serial(self._client.device_serial_number), self._type, summary, ) return ( future_active[0][1] if future_active else (self._active[0][1] if self._active else None) ) def _process_raw_notifications(self): # Build full list for this device/type self._all = ( list(map(self._fix_alarm_date_time, self._n_dict.items())) if self._n_dict else [] ) self._all = list(map(self._normalize_alarm_snooze_state, self._all)) self._all = list(map(self._update_recurring_alarm, self._all)) self._all = sorted(self._all, key=lambda x: x[1][self._sensor_property]) # DEBUG: log ALL notifications for this device/type if self._debug and self._all: try: summary_all = [ { "id": v.get("id"), "status": v.get("status"), self._sensor_property: v.get(self._sensor_property), "snoozedToTime": v.get("snoozedToTime"), "lastUpdatedDate": v.get("lastUpdatedDate"), "type": v.get("type"), } for _, v in self._all ] except (KeyError, TypeError, AttributeError) as exc: summary_all = f"" _LOGGER.debug( "%s: %s %s ALL notifications: %s", hide_email(self._account), hide_serial(self._client.device_serial_number), self._type, summary_all, ) elif getattr(self, "_debug", False): _LOGGER.debug( "%s: %s %s has no notifications (_n_dict empty)", hide_email(self._account), hide_serial(self._client.device_serial_number), self._type, ) # Previous "next" for change detection self._prior_value = self._next if self._active else None now = dt.now() # Filter ACTIVE (ON / SNOOZED, excluding expired snoozes) self._active = list( filter(lambda item: self._is_active_notification(item, now), self._all) ) if self._type == "Alarm": self._next = self._select_next_alarm(now) else: self._next = self._active[0][1] if self._active else None # DEBUG: log ACTIVE set and which one we picked as next if self._debug and self._active: try: summary_active = [ { "id": v.get("id"), "status": v.get("status"), self._sensor_property: v.get(self._sensor_property), "snoozedToTime": v.get("snoozedToTime"), "lastUpdatedDate": v.get("lastUpdatedDate"), "type": v.get("type"), } for _, v in self._active ] except (KeyError, TypeError, AttributeError) as exc: summary_active = f"" _LOGGER.debug( "%s: %s %s ACTIVE notifications: %s | picked next=%s", hide_email(self._account), hide_serial(self._client.device_serial_number), self._type, summary_active, self._next.get("id") if self._next else None, ) elif getattr(self, "_debug", False): _LOGGER.debug( "%s: %s %s has no ACTIVE notifications (all=%s)", hide_email(self._account), hide_serial(self._client.device_serial_number), self._type, len(self._all), ) # Track dismissal and schedule events (existing behavior) alarm = next( (alarm[1] for alarm in self._all if alarm[1].get("id") == self._amz_id), None, ) if alarm_just_dismissed(alarm, self._status, self._version): self._dismissed = dt.now().isoformat() self._attr_native_value = self._process_state(self._next) self._status = self._next.get("status", "OFF") if self._next else "OFF" self._version = self._next.get("version", "0") if self._next else None self._amz_id = self._next.get("id") if self._next else None if self._attr_native_value is None or self._next != self._prior_value: # cancel any event triggers if self._tracker: _LOGGER.debug("%s: Cancelling old event", self) self._tracker() if self._attr_native_value is not None and self._status != "SNOOZED": _LOGGER.debug( "%s: Scheduling event in %s", self, dt.as_utc(self._attr_native_value) - dt.utcnow(), ) self._tracker = async_track_point_in_utc_time( self.hass, self._trigger_event, dt.as_utc(self._attr_native_value), ) def _trigger_event(self, time_date) -> None: _LOGGER.debug( "%s:Firing %s at %s", self, "alexa_media_notification_event", dt.as_local(time_date), ) self.hass.bus.fire( "alexa_media_notification_event", event_data={ "email": hide_email(self._account), "device": {"name": self.name, "entity_id": self.entity_id}, "event": self._active[0], }, ) def _fix_alarm_date_time(self, value): if ( self._sensor_property != "date_time" or not value or isinstance(value[1][self._sensor_property], datetime.datetime) ): return value naive_time = dt.parse_datetime(value[1][self._sensor_property]) timezone = dt.get_time_zone( self._client._timezone # pylint: disable=protected-access ) if timezone and naive_time: value[1][self._sensor_property] = naive_time.replace(tzinfo=timezone) elif not naive_time: # this is typically an older alarm value[1][self._sensor_property] = datetime.datetime.fromtimestamp( value[1]["alarmTime"] / 1000, tz=LOCAL_TIMEZONE ) _LOGGER.warning( "There is an old format alarm on %s set for %s. " " This alarm should be removed in the Alexa app and recreated. ", self._client.name, dt.as_local(value[1][self._sensor_property]), ) else: _LOGGER.warning( "%s is returning erroneous data. " "Returned times may be wrong. " "Please confirm the timezone in the Alexa app is correct. " "Debugging info: \nRaw: %s \nNaive Time: %s " "\nTimezone: %s", self._client.name, value[1], naive_time, self._client._timezone, # pylint: disable=protected-access ) return value def _update_recurring_alarm(self, value): if getattr(self, "_debug", False): _LOGGER.debug("Sensor value %s", value) next_item = value[1] alarm = next_item[self._sensor_property] reminder = None recurrence = [] if isinstance(next_item[self._sensor_property], (int, float)): reminder = True alarm = dt.as_local( self._round_time( datetime.datetime.fromtimestamp(alarm / 1000, tz=LOCAL_TIMEZONE) ) ) alarm_status = next_item.get("status") alarm_on = alarm_status == "ON" alarm_snoozed = alarm_status == "SNOOZED" # Preserve snoozed alarms exactly as normalized above. if alarm_snoozed: if reminder: alarm = dt.as_timestamp(alarm) * 1000 if alarm != next_item[self._sensor_property]: next_item[self._sensor_property] = alarm return value r_rule_data = next_item.get("rRuleData") if r_rule_data: next_trigger_times = r_rule_data.get("nextTriggerTimes") weekdays = r_rule_data.get("byWeekDays") if next_trigger_times: alarm = next_trigger_times[0] elif weekdays: for day in weekdays: recurrence.append(RECURRING_DAY[day]) else: recurring_pattern = next_item.get("recurringPattern") recurrence = RECURRING_PATTERN_ISO_SET.get(recurring_pattern) while ( alarm_on and recurrence and alarm.isoweekday() not in recurrence and alarm < dt.now() ): alarm += datetime.timedelta(days=1) if reminder: alarm = dt.as_timestamp(alarm) * 1000 if alarm != next_item[self._sensor_property]: _LOGGER.debug( "%s with recurrence %s set to %s", next_item["type"], recurrence, alarm, ) next_item[self._sensor_property] = alarm return value @staticmethod def _round_time(value: datetime.datetime) -> datetime.datetime: precision = datetime.timedelta(seconds=1).total_seconds() seconds = (value - value.min.replace(tzinfo=value.tzinfo)).seconds rounding = (seconds + precision / 2) // precision * precision return value + datetime.timedelta(0, rounding - seconds, -value.microsecond) async def async_added_to_hass(self): """Store register state change callback.""" try: if not self.enabled: return except AttributeError: pass self._process_raw_notifications() # Register event handler on bus self._listener = async_dispatcher_connect( self.hass, f"{ALEXA_DOMAIN}_{hide_email(self._account)}"[0:32], self._handle_event, ) await self.async_update() async def async_will_remove_from_hass(self): """Prepare to remove entity.""" # Register event handler on bus self._listener() if self._tracker: self._tracker() def _handle_event(self, event): """Handle events. This will update PUSH_ACTIVITY, NOTIFICATION_UPDATE, or a global notifications refresh event to see if the sensor should be updated. """ try: if not self.enabled: return except AttributeError: pass # Global refresh: any time we rebuild the notifications snapshot if "notifications_refreshed" in event: _LOGGER.debug("Force-refreshing notification sensor %s", self) self.schedule_update_ha_state(True) return if "notification_update" in event: if ( event["notification_update"]["dopplerId"]["deviceSerialNumber"] == self._client.device_serial_number ): _LOGGER.debug("Updating sensor %s from notification_update", self) self.schedule_update_ha_state(True) if "push_activity" in event: if ( event["push_activity"]["key"]["serialNumber"] == self._client.device_serial_number ): _LOGGER.debug("Updating sensor %s from push_activity", self) self.schedule_update_ha_state(True) @property def hidden(self): """Return whether the sensor should be hidden.""" return self.state is None @property def should_poll(self): """Return the polling state.""" return not is_http2_enabled(self.hass, self._account) def _process_state(self, value) -> Optional[datetime.datetime]: return dt.as_local(value[self._sensor_property]) if value else None async def async_update(self): """Update state.""" try: if not self.enabled: return except AttributeError: pass account_dict = self.hass.data[DATA_ALEXAMEDIA]["accounts"][self._account] notifications = account_dict.get("notifications") if notifications is None: _LOGGER.debug( "%s: No notifications found in account_dict yet; skipping update", self, ) self._timestamp = None self._n_dict = None self._process_raw_notifications() try: self.schedule_update_ha_state() except NoEntitySpecifiedError: pass return # Normal path: notifications dict present self._timestamp = notifications.get("process_timestamp") device_notifications = notifications.get(self._client.device_serial_number, {}) self._n_dict = device_notifications.get(self._type, {}) self._process_raw_notifications() try: self.schedule_update_ha_state() except NoEntitySpecifiedError: pass # we ignore this due to a harmless startup race condition @property def recurrence(self): """Return the recurrence pattern of the sensor.""" return ( RECURRING_PATTERN.get(self._next.get("recurringPattern")) if self._next else None ) @property def extra_state_attributes(self): """Return additional attributes.""" attr = { "recurrence": self.recurrence, "process_timestamp": ( dt.as_local(self._timestamp).isoformat() if self._timestamp else None ), "prior_value": self._process_state(self._prior_value), "total_active": len(self._active), "total_all": len(self._all), "status": self._status, "dismissed": self._dismissed, } # Optional lightweight debug/introspection view # Only include a small subset and rely on _unrecorded_attributes # so this doesn't end up in the recorder DB. def _serialize_entry(entry: dict) -> dict: """Serialize a single alarm/timer/reminder entry into a compact dict.""" if not entry: return {} when = entry.get(self._sensor_property) if isinstance(when, datetime.datetime): when_val = dt.as_local(when).isoformat() else: when_val = when # Labels are type-specific in Alexa's payload; resolve to a single generic key. label_key = self._LABEL_KEY_MAP.get(self._type) label = entry.get(label_key) if label_key else None data = { "id": entry.get("id"), "label": label, "status": entry.get("status"), "type": entry.get("type"), "version": entry.get("version"), self._sensor_property: when_val, "lastUpdatedDate": entry.get("lastUpdatedDate"), } return data if self._all: # Limit to a few entries so attributes stay small attr["brief"] = { "active": [_serialize_entry(v) for _, v in self._active[:12]], "all": [_serialize_entry(v) for _, v in self._all[:12]], } # Legacy alias attributes (for backwards compatibility with # cards/automations that relied on the previous sorted_* attributes). # # Historical behavior exposed the full notification dicts legacy_all = [v for _, v in self._all] legacy_active = [v for _, v in self._active] # Generic legacy names attr["sorted_all"] = legacy_all attr["sorted_active"] = legacy_active # Some consumers expect a single string label for the "next" item. # These keys are used by card-alexa-alarms-timers. if legacy_active: first = legacy_active[0] label_key = self._LABEL_KEY_MAP.get(self._type) if label_key: attr[self._type.lower()] = first.get(label_key) if self._type == "Reminder": # Secondary reminder label (when present) attr["reminder_sub_label"] = first.get("reminderSubLabel") return attr class AlarmSensor(AlexaMediaNotificationSensor): """Representation of a Alexa Alarm sensor.""" _attr_translation_key = "next_alarm" def __init__(self, client, n_json, account, debug: bool = False): """Initialize the Alexa sensor.""" # Class info self._type = "Alarm" super().__init__( client, n_json, "date_time", account, f"next {self._type}", "mdi:alarm", debug=debug, ) class TimerSensor(AlexaMediaNotificationSensor): """Representation of a Alexa Timer sensor.""" _attr_translation_key = "next_timer" def __init__(self, client, n_json, account, debug: bool = False): """Initialize the Alexa sensor.""" # Class info self._type = "Timer" super().__init__( client, n_json, "remainingTime", account, f"next {self._type}", ( "mdi:timer-outline" if (version.parse(HA_VERSION) >= version.parse("0.113.0")) else "mdi:timer" ), debug=debug, ) def _process_state(self, value) -> Optional[datetime.datetime]: return ( dt.as_local( super()._round_time( self._timestamp + datetime.timedelta(milliseconds=value[self._sensor_property]) ) ) if value and self._timestamp else None ) @property def paused(self) -> Optional[bool]: """Return the paused state of the sensor.""" return self._next.get("status") == "PAUSED" if self._next else None @property def icon(self): """Return the icon of the sensor.""" off_icon = ( "mdi:timer-off-outline" if (version.parse(HA_VERSION) >= version.parse("0.113.0")) else "mdi:timer-off" ) return self._attr_icon if not self.paused else off_icon @property def timer(self): """Return the timer of the sensor.""" return self._next.get("timerLabel") if self._next else None @property def extra_state_attributes(self): """Return the scene state attributes.""" attr = super().extra_state_attributes attr.update({"timer": self.timer}) return attr class ReminderSensor(AlexaMediaNotificationSensor): """Representation of a Alexa Reminder sensor.""" _attr_translation_key = "next_reminder" def __init__(self, client, n_json, account, debug: bool = False): """Initialize the Alexa sensor.""" self._type = "Reminder" super().__init__( client, n_json, "alarmTime", account, f"next {self._type}", "mdi:reminder", debug=debug, ) def _process_state(self, value) -> Optional[datetime.datetime]: return ( dt.as_local( super()._round_time( datetime.datetime.fromtimestamp( value[self._sensor_property] / 1000, tz=LOCAL_TIMEZONE ) ) ) if value else None ) @property def reminder(self): """Return the reminder of the sensor.""" return self._next.get("reminderLabel") if self._next else None @property def extra_state_attributes(self): """Return the scene state attributes.""" attr = super().extra_state_attributes attr.update({"reminder": self.reminder}) return attr