diff --git a/custom_components/saas/sensor.py b/custom_components/saas/sensor.py index b480607..00cc7e4 100644 --- a/custom_components/saas/sensor.py +++ b/custom_components/saas/sensor.py @@ -10,13 +10,9 @@ from homeassistant.util import dt as dt_util from homeassistant.components import mqtt from .const import DOMAIN, CONF_NAME, CONF_TOPIC, CONF_AWAKE_STATES, CONF_SLEEP_STATES, CONF_AWAKE_DURATION, CONF_SLEEP_DURATION, INTEGRATION_NAME, MODEL, STATE_MAPPING, SOUND_MAPPING, DISTURBANCE_MAPPING, ALARM_EVENT_MAPPING, SLEEP_TRACKING_MAPPING, LULLABY_MAPPING, REVERSE_STATE_MAPPING, SLEEP_STAGE_MAPPING - _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) -import inspect -from datetime import datetime - class SAASSensor(RestoreEntity): """Representation of a SAAS - Sleep As Android Stats sensor.""" @@ -136,13 +132,15 @@ class SAASAlarmEventSensor(RestoreEntity): @property def extra_state_attributes(self): """Return the extra state attributes.""" - return { - "Last Event": self._last_event, - "Message": self._value2 if self._value2 else "No message received", - "Timestamp": self._value1 if self._value1 else "No timestamp received", - "Time": self._time.strftime('%H:%M') if self._time else "No time received", - "Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received", - } + if self._state is not None: + return { + "Last Event": self._last_event, + "Message": self._value2 if self._value2 else "No message received", + "Timestamp": self._value1 if self._value1 else "No timestamp received", + "Time": self._time.strftime('%H:%M') if self._time else "No time received", + "Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received", + } + return {} async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -193,7 +191,7 @@ class SAASAlarmEventSensor(RestoreEntity): return # Use the mapping to convert the event to the corresponding state - new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found + new_state = self._mapping.get(event) # Default to "None" if no mapping found if new_state is not None: _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}") self._state = new_state @@ -219,23 +217,29 @@ class SAASAlarmEventSensor(RestoreEntity): self._state = "None" self._last_event = "None" _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to 'None' due to timeout for sensor {self.name}") - self.async_schedule_update_ha_state() + self.async_schedule_update_ha_state() class SAASSoundSensor(RestoreEntity): """Representation of a SAAS - Sleep As Android Stats sensor for Sound Events.""" + def __init__(self, hass, name, mapping, entry_id): """Initialize the sensor.""" self._state = None self._name = name self._hass = hass self._mapping = mapping + self._value1 = None + self._value2 = None + self._time = None self.entry_id = entry_id + self._last_event = None + self._timeout_task = None @property def unique_id(self): """Return a unique ID.""" return f"saas_sound_sensor_{self._name}" - + @property def name(self): """Return the name of the sensor.""" @@ -245,7 +249,7 @@ class SAASSoundSensor(RestoreEntity): def state(self): """Return the state of the sensor.""" return self._state - + @property def device_info(self): """Return information about the device.""" @@ -255,6 +259,16 @@ class SAASSoundSensor(RestoreEntity): "manufacturer": INTEGRATION_NAME, "model": MODEL, } + + @property + def extra_state_attributes(self): + """Return the extra state attributes.""" + return { + "Last Event": self._last_event, + "Timestamp": self._value1 if self._value1 else "No timestamp received", + "Time": self._time.strftime('%H:%M') if self._time else "No time received", + "Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received", + } async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -266,8 +280,15 @@ class SAASSoundSensor(RestoreEntity): self._state = state.state _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}") + # Start the timeout task as soon as the sensor is loaded + self._timeout_task = asyncio.create_task(self.timeout()) + async def message_received(msg): """Handle new MQTT messages.""" + # Cancel the previous timeout task if it exists + if self._timeout_task: + self._timeout_task.cancel() + # Parse the incoming message msg_json = json.loads(msg.payload) @@ -281,13 +302,35 @@ class SAASSoundSensor(RestoreEntity): return # Use the mapping to convert the event to the corresponding state - new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found + new_state = self._mapping.get(event) if new_state is not None: _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}") self._state = new_state + self._last_event = new_state # Update the last event _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}") + + # Extract the 'value1' and 'value2' fields + value1 = msg_json.get('value1') + + # Parse 'value1' as a datetime + if value1: + timestamp = int(value1) / 1000.0 + self._time = datetime.fromtimestamp(timestamp) + self._value1 = value1 + _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}") + + # Extract the 'value2' field + value2 = msg_json.get('value2') + + # Store 'value2' as the message if it exists + self._value2 = value2 if value2 else "None" + _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}") + self.async_schedule_update_ha_state() + # Create a new timeout task + self._timeout_task = asyncio.create_task(self.timeout()) + # Subscribe to the topic from the user input await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received) @@ -297,6 +340,14 @@ class SAASSoundSensor(RestoreEntity): self._hass.states.async_set(self.entity_id, self._state) _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}") + async def timeout(self): + """Set the state to 'None' after a timeout.""" + await asyncio.sleep(15) + self._state = "None" + self._last_event = "None" + _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to 'None' due to timeout for sensor {self.name}") + self.async_schedule_update_ha_state() + class SAASSleepTrackingSensor(RestoreEntity): """Representation of a SAAS - Sleep As Android Stats sensor for Sleep Tracking.""" def __init__(self, hass, name, mapping, entry_id): @@ -374,19 +425,25 @@ class SAASSleepTrackingSensor(RestoreEntity): class SAASDisturbanceSensor(RestoreEntity): """Representation of a SAAS - Sleep As Android Stats sensor for Disturbance Events.""" + def __init__(self, hass, name, mapping, entry_id): """Initialize the sensor.""" self._state = None self._name = name self._hass = hass self._mapping = mapping + self._value1 = None + self._value2 = None + self._time = None self.entry_id = entry_id + self._last_event = None + self._timeout_task = None @property def unique_id(self): """Return a unique ID.""" return f"saas_disturbance_sensor_{self._name}" - + @property def name(self): """Return the name of the sensor.""" @@ -396,7 +453,7 @@ class SAASDisturbanceSensor(RestoreEntity): def state(self): """Return the state of the sensor.""" return self._state - + @property def device_info(self): """Return information about the device.""" @@ -406,6 +463,16 @@ class SAASDisturbanceSensor(RestoreEntity): "manufacturer": INTEGRATION_NAME, "model": MODEL, } + + @property + def extra_state_attributes(self): + """Return the extra state attributes.""" + return { + "Last Event": self._last_event, + "Timestamp": self._value1 if self._value1 else "No timestamp received", + "Time": self._time.strftime('%H:%M') if self._time else "No time received", + "Date": self._time.strftime('%m/%d/%Y') if self._time else "No date received", + } async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -417,8 +484,15 @@ class SAASDisturbanceSensor(RestoreEntity): self._state = state.state _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}") + # Start the timeout task as soon as the sensor is loaded + self._timeout_task = asyncio.create_task(self.timeout()) + async def message_received(msg): """Handle new MQTT messages.""" + # Cancel the previous timeout task if it exists + if self._timeout_task: + self._timeout_task.cancel() + # Parse the incoming message msg_json = json.loads(msg.payload) @@ -432,10 +506,34 @@ class SAASDisturbanceSensor(RestoreEntity): return # Use the mapping to convert the event to the corresponding state - new_state = self._mapping.get(event, "None") # Default to "None" if no mapping found - self._state = new_state - _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}") - self.async_schedule_update_ha_state() + new_state = self._mapping.get(event) + if new_state is not None: + _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {new_state} for sensor {self.name}") + self._state = new_state + self._last_event = new_state # Update the last event + _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to {new_state} for sensor {self.name}") + + # Extract the 'value1' and 'value2' fields + value1 = msg_json.get('value1') + + # Parse 'value1' as a datetime + if value1: + timestamp = int(value1) / 1000.0 + self._time = datetime.fromtimestamp(timestamp) + self._value1 = value1 + _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Parsed 'value1' as datetime: {self._time} for sensor {self.name}") + + # Extract the 'value2' field + value2 = msg_json.get('value2') + + # Store 'value2' as the message if it exists + self._value2 = value2 if value2 else "None" + _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Stored 'value2' as message: {self._value2} for sensor {self.name}") + + self.async_schedule_update_ha_state() + + # Create a new timeout task + self._timeout_task = asyncio.create_task(self.timeout()) # Subscribe to the topic from the user input await async_subscribe(self._hass, self._hass.data[DOMAIN][self.entry_id][CONF_TOPIC], message_received) @@ -446,6 +544,14 @@ class SAASDisturbanceSensor(RestoreEntity): self._hass.states.async_set(self.entity_id, self._state) _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}") + async def timeout(self): + """Set the state to 'None' after a timeout.""" + await asyncio.sleep(15) + self._state = "None" + self._last_event = "None" + _LOGGER.debug(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Set state to 'None' due to timeout for sensor {self.name}") + self.async_schedule_update_ha_state() + class SAASLullabySensor(RestoreEntity): """Representation of a SAAS - Sleep As Android Stats sensor for Lullaby.""" def __init__(self, hass, name, mapping, entry_id): @@ -598,8 +704,6 @@ class SAASWakeStatusSensor(RestoreEntity): def __init__(self, hass, name, awake_states, sleep_states, awake_duration, sleep_duration, mqtt_topic, entry_id): self._state = None self._name = name - self.awake_bucket = [] - self.sleep_bucket = [] self.awake_duration = timedelta(seconds=awake_duration) self.sleep_duration = timedelta(seconds=sleep_duration) self.awake_states = awake_states @@ -607,6 +711,8 @@ class SAASWakeStatusSensor(RestoreEntity): self.hass = hass self.mqtt_topic = mqtt_topic self.entry_id = entry_id + self.awake_timer = None + self.sleep_timer = None @property def unique_id(self): @@ -636,93 +742,57 @@ class SAASWakeStatusSensor(RestoreEntity): def process_message(self, message): try: now = dt_util.utcnow() # Define 'now' before using it for logging - + # Extract the 'event' from the incoming message event = message.get('event') _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Extracted Event {event} from message.") - + # Map the event to a known state, or "Unknown" if the event is not recognized mapped_value = STATE_MAPPING.get(event, "Unknown") _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped {event} to {mapped_value}.") - + # If the event could not be mapped to a known state, set the sensor state to "Unknown" if mapped_value == "Unknown": self._state = "Unknown" _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Event {event} could not be mapped to a known state. Setting sensor state to 'Unknown'.") - - # If the mapped value is in the awake states, add it to the awake bucket and clear the sleep bucket + + # If the mapped value is in the awake states, start or restart the awake timer and cancel the sleep timer if mapped_value in self.awake_states: - self.awake_bucket.append((mapped_value, now)) - self.sleep_bucket = [] - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in awake states. Adding to awake bucket and clearing sleep bucket.") - - # If the mapped value is in the sleep states, add it to the sleep bucket and clear the awake bucket + if self.awake_timer: + self.awake_timer.cancel() + self.awake_timer = self.hass.loop.call_later(self.awake_duration.total_seconds(), self.set_state, "Awake") + if self.sleep_timer: + self.sleep_timer.cancel() + self.sleep_timer = None + _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in awake states. Starting or restarting awake timer and cancelling sleep timer.") + + # If the mapped value is in the sleep states, start or restart the sleep timer and cancel the awake timer elif mapped_value in self.sleep_states: - self.sleep_bucket.append((mapped_value, now)) - self.awake_bucket = [] - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in sleep states. Adding to sleep bucket and clearing awake bucket.") - + if self.sleep_timer: + self.sleep_timer.cancel() + self.sleep_timer = self.hass.loop.call_later(self.sleep_duration.total_seconds(), self.set_state, "Asleep") + if self.awake_timer: + self.awake_timer.cancel() + self.awake_timer = None + _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Mapped value {mapped_value} is in sleep states. Starting or restarting sleep timer and cancelling awake timer.") + except Exception as e: _LOGGER.error(f"Error processing message: {e}") - - async def async_update(self, _=None): - """Update the state.""" - now = dt_util.utcnow() - - # If any message in the awake bucket has reached the awake duration, set the state to "Awake" - if self.awake_bucket and any(now - timestamp >= self.awake_duration for _, timestamp in self.awake_bucket): - if self._state != "Awake": - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): State changed to 'Awake'") - self._state = "Awake" - - # If any message in the sleep bucket has reached the sleep duration, set the state to "Asleep" - elif self.sleep_bucket and any(now - timestamp >= self.sleep_duration for _, timestamp in self.sleep_bucket): - if self._state != "Asleep": - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): State changed to 'Asleep'") - self._state = "Asleep" - - # Remove messages from the awake bucket that are older than the awake duration and log if a message is removed - self.awake_bucket = [(val, timestamp) for val, timestamp in self.awake_bucket if now - timestamp < self.awake_duration] - for val, timestamp in self.awake_bucket: - if now - timestamp >= self.awake_duration: - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Removed message from awake bucket.") - - # Remove messages from the sleep bucket that are older than the sleep duration and log if a message is removed - self.sleep_bucket = [(val, timestamp) for val, timestamp in self.sleep_bucket if now - timestamp < self.sleep_duration] - for val, timestamp in self.sleep_bucket: - if now - timestamp >= self.sleep_duration: - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Removed message from sleep bucket.") - - # Log the contents of the awake bucket if it is not empty - if self.awake_bucket: - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Awake bucket: {self.awake_bucket}") - - # Log the contents of the sleep bucket if it is not empty - if self.sleep_bucket: - _LOGGER.debug(f"{dt_util.as_local(now).strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Sleep bucket: {self.sleep_bucket}") - async def interval_callback(self, _): - """Wrapper function for async_track_time_interval.""" - # Call the async_update method to update the state - await self.async_update() - + def set_state(self, state): + self._state = state + self.async_schedule_update_ha_state() + async def async_added_to_hass(self): """Run when entity about to be added.""" await super().async_added_to_hass() - + # Load the previous state from the state machine state = await self.async_get_last_state() if state: self._state = state.state _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Loaded state: {self._state} for sensor {self.name}") - - # Schedule the interval callback to run every second - async_track_time_interval( - self.hass, - self.interval_callback, - timedelta(seconds=1) - ) - + # Subscribe to the MQTT topic to receive messages await mqtt.async_subscribe( self.hass, @@ -735,7 +805,7 @@ class SAASWakeStatusSensor(RestoreEntity): # Save the current state to the state machine self.hass.states.async_set(self.entity_id, self._state) _LOGGER.info(f"{datetime.now().strftime('%H:%M:%S:%f')} (Line {inspect.currentframe().f_lineno}): Saved state: {self._state} for sensor {self.name}") - + async def async_setup_entry(hass, entry, async_add_entities): """Set up the SAAS sensor platform from a config entry.""" name = entry.data.get(CONF_NAME, "Default Name")