"""Watchman sensors definition.""" from typing import Any from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) from homeassistant.components.sensor.const import ( SensorDeviceClass, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import MATCH_ALL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( COORD_DATA_ENTITY_ATTRS, COORD_DATA_IGNORED_FILES, COORD_DATA_LAST_PARSE, COORD_DATA_LAST_UPDATE, COORD_DATA_MISSING_ACTIONS, COORD_DATA_MISSING_ENTITIES, COORD_DATA_PARSE_DURATION, COORD_DATA_PROCESSED_FILES, COORD_DATA_SERVICE_ATTRS, DOMAIN, SENSOR_IGNORED_FILES, SENSOR_LAST_PARSE, SENSOR_LAST_UPDATE, SENSOR_MISSING_ACTIONS, SENSOR_MISSING_ENTITIES, SENSOR_PARSE_DURATION, SENSOR_PROCESSED_FILES, SENSOR_STATUS, STATE_IDLE, STATE_PARSING, STATE_PENDING, STATE_SAFE_MODE, STATE_WAITING_HA, ) from .entity import WatchmanEntity from .utils.logger import _LOGGER SENSORS_CONFIGURATION = [ SensorEntityDescription( key=SENSOR_LAST_UPDATE, translation_key="last_updated", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:calendar-clock", ), SensorEntityDescription( key=SENSOR_MISSING_ENTITIES, translation_key="missing_entities", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:shield-half-full", native_unit_of_measurement="items", ), SensorEntityDescription( key=SENSOR_MISSING_ACTIONS, translation_key="missing_actions", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:shield-half-full", native_unit_of_measurement="items", ), SensorEntityDescription( key=SENSOR_STATUS, translation_key="status", device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[STATE_WAITING_HA, STATE_PARSING, STATE_PENDING, STATE_IDLE, STATE_SAFE_MODE], ), SensorEntityDescription( key=SENSOR_PARSE_DURATION, translation_key="parse_duration", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="s", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:timer-outline", ), SensorEntityDescription( key=SENSOR_LAST_PARSE, translation_key="last_parse", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:calendar-clock", ), SensorEntityDescription( key=SENSOR_PROCESSED_FILES, translation_key="processed_files", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:file-document-check", ), SensorEntityDescription( key=SENSOR_IGNORED_FILES, translation_key="ignored_files", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:file-remove", ), ] async def update_or_cleanup_entity( ent_reg: er.EntityRegistry, old_uid: str, new_uid: str ) -> None: if old_entity_id := ent_reg.async_get_entity_id("sensor", DOMAIN, old_uid): # we found entities with old-style uid in registry, apply migration logic if ent_reg.async_get_entity_id("sensor", DOMAIN, new_uid): ent_reg.async_remove(old_entity_id) _LOGGER.debug(f"async_setup_entry: 2 entities found in registry. Will remove {old_uid} in favor of {new_uid}.") else: _LOGGER.debug(f"async_setup_entry: Entity with old uid {old_uid} was migrated to {new_uid}.") ent_reg.async_update_entity(old_entity_id, new_unique_id=new_uid) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_devices: AddEntitiesCallback ) -> None: """Set up sensor platform.""" _LOGGER.debug("async_setup_entry called") coordinator = hass.data[DOMAIN][entry.entry_id] ent_reg = er.async_get(hass) entities = [] for description in SENSORS_CONFIGURATION: # migration logic # fixing the bug in WM prior to 8.x where sensor uids were generated using entry uid # which led to duplication of entities after integration reinstall # e.g. 0A3F1123_watchman_status -> watchman_status old_uid = f"{entry.entry_id}_{DOMAIN}_{description.key}" new_uid = f"{DOMAIN}_{description.key}" await update_or_cleanup_entity(ent_reg, old_uid, new_uid) # fix for duplicated domain uid, introduced by first dev versions of 0.8 # e.g. watchman_watchman_status -> watchman_status # FIXME: for development versions only, remove this code after 0.8.3 is released dub_uid = f"{DOMAIN}_{DOMAIN}_{description.key}" await update_or_cleanup_entity(ent_reg, dub_uid, new_uid) # Instantiate sensor classes if description.key == SENSOR_LAST_UPDATE: entities.append(LastUpdateSensor(coordinator, description)) elif description.key == SENSOR_MISSING_ENTITIES: entities.append(MissingEntitiesSensor(coordinator, description)) elif description.key == SENSOR_MISSING_ACTIONS: entities.append(MissingActionsSensor(coordinator, description)) elif description.key == SENSOR_STATUS: entities.append(StatusSensor(coordinator, description)) elif description.key == SENSOR_PARSE_DURATION: entities.append(ParseDurationSensor(coordinator, description)) elif description.key == SENSOR_LAST_PARSE: entities.append(LastParseSensor(coordinator, description)) elif description.key == SENSOR_PROCESSED_FILES: entities.append(ProcessedFilesSensor(coordinator, description)) elif description.key == SENSOR_IGNORED_FILES: entities.append(IgnoredFilesSensor(coordinator, description)) async_add_devices(entities) class LastUpdateSensor(WatchmanEntity, SensorEntity): """Timestamp sensor for last watchman update time.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_LAST_UPDATE) return None @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_LAST_UPDATE) self.async_write_ha_state() super()._handle_coordinator_update() class MissingEntitiesSensor(WatchmanEntity, SensorEntity): """Number of missing entities from watchman report.""" _attr_should_poll = False _attr_has_entity_name = True _unrecorded_attributes = frozenset({MATCH_ALL}) @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_MISSING_ENTITIES) return None @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.coordinator.data: return {"entities": self.coordinator.data.get(COORD_DATA_ENTITY_ATTRS, [])} return {} @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_MISSING_ENTITIES) self._attr_extra_state_attributes = { "entities": self.coordinator.data.get(COORD_DATA_ENTITY_ATTRS, []) } self.async_write_ha_state() super()._handle_coordinator_update() class MissingActionsSensor(WatchmanEntity, SensorEntity): """Number of missing services from watchman report.""" _attr_should_poll = False _attr_has_entity_name = True _unrecorded_attributes = frozenset({MATCH_ALL}) @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_MISSING_ACTIONS) return None @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.coordinator.data: return {"entities": self.coordinator.data.get(COORD_DATA_SERVICE_ATTRS, [])} return {} @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_MISSING_ACTIONS) self._attr_extra_state_attributes = { "services": self.coordinator.data.get(COORD_DATA_SERVICE_ATTRS, []) } self.async_write_ha_state() super()._handle_coordinator_update() class StatusSensor(WatchmanEntity, SensorEntity): """Diagnostic sensor for Watchman status.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> str | None: """Return the native value of the sensor.""" return self.coordinator.status @property def icon(self) -> str: """Return dynamic icon based on status.""" if self.coordinator.status == STATE_PARSING: return "mdi:progress-clock" if self.coordinator.status == STATE_PENDING: return "mdi:timer-sand" if self.coordinator.status == STATE_IDLE: return "mdi:sleep" if self.coordinator.status == STATE_SAFE_MODE: return "mdi:shield-alert" return "mdi:timer-sand" @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" self._attr_native_value = self.coordinator.status self.async_write_ha_state() super()._handle_coordinator_update() class ParseDurationSensor(WatchmanEntity, SensorEntity): """Sensor for last parse duration.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_PARSE_DURATION) return None @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_PARSE_DURATION) self.async_write_ha_state() super()._handle_coordinator_update() class LastParseSensor(WatchmanEntity, SensorEntity): """Timestamp sensor for last parse time.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_LAST_PARSE) return None @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_LAST_PARSE) self.async_write_ha_state() super()._handle_coordinator_update() class ProcessedFilesSensor(WatchmanEntity, SensorEntity): """Sensor for number of processed files.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_PROCESSED_FILES) return None @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_PROCESSED_FILES) self.async_write_ha_state() super()._handle_coordinator_update() class IgnoredFilesSensor(WatchmanEntity, SensorEntity): """Sensor for number of ignored files.""" _attr_should_poll = False _attr_has_entity_name = True @property def should_poll(self) -> bool: """No polling needed.""" return False @property def native_value(self) -> Any: """Return the native value of the sensor.""" if self.coordinator.data: return self.coordinator.data.get(COORD_DATA_IGNORED_FILES) return None @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if self.coordinator.data: self._attr_native_value = self.coordinator.data.get(COORD_DATA_IGNORED_FILES) self.async_write_ha_state() super()._handle_coordinator_update()