309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""
|
|
Session Detector with Aggregation - State Machine for IoT Bridge
|
|
|
|
State Machine:
|
|
IDLE → STARTING → STANDBY → WORKING → STOPPING → IDLE
|
|
↓ ↓ ↓
|
|
IDLE (STANDBY ↔ WORKING)
|
|
|
|
Aggregation:
|
|
- Tracks state internally every message (1 msg/s)
|
|
- Accumulates interval_working_s, interval_standby_s
|
|
- Emits aggregated heartbeat events every heartbeat_interval_s (e.g., 300s)
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, Dict, Callable
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SessionDetector:
|
|
"""
|
|
State Machine for Power-Based Session Detection with Aggregation.
|
|
|
|
Emits events:
|
|
- session_started: When session begins
|
|
- session_heartbeat: Periodic aggregated updates
|
|
- session_ended: When session ends normally
|
|
- session_timeout: When session times out
|
|
"""
|
|
|
|
def __init__(self, device_id: str, machine_name: str,
|
|
standby_threshold_w: float,
|
|
working_threshold_w: float,
|
|
start_debounce_s: float,
|
|
stop_debounce_s: float,
|
|
message_timeout_s: float,
|
|
heartbeat_interval_s: float,
|
|
event_callback: Optional[Callable] = None):
|
|
"""
|
|
Initialize Session Detector.
|
|
|
|
Args:
|
|
device_id: Device identifier
|
|
machine_name: Human-readable machine name
|
|
standby_threshold_w: Power threshold for session start (e.g., 20W)
|
|
working_threshold_w: Power threshold for working state (e.g., 100W)
|
|
start_debounce_s: Debounce time for session start (e.g., 3s)
|
|
stop_debounce_s: Debounce time for session stop (e.g., 15s)
|
|
message_timeout_s: Max time without messages before timeout (e.g., 20s)
|
|
heartbeat_interval_s: Interval for aggregated heartbeat events (e.g., 300s)
|
|
event_callback: Callback function(event_dict) for emitting events
|
|
"""
|
|
self.device_id = device_id
|
|
self.machine_name = machine_name
|
|
self.standby_threshold_w = standby_threshold_w
|
|
self.working_threshold_w = working_threshold_w
|
|
self.start_debounce_s = start_debounce_s
|
|
self.stop_debounce_s = stop_debounce_s
|
|
self.message_timeout_s = message_timeout_s
|
|
self.heartbeat_interval_s = heartbeat_interval_s
|
|
self.event_callback = event_callback
|
|
|
|
# State
|
|
self.state = 'idle'
|
|
self.current_session_id: Optional[str] = None
|
|
|
|
# Timing
|
|
self.state_entered_at: Optional[datetime] = None
|
|
self.last_message_time: Optional[datetime] = None
|
|
self.session_start_time: Optional[datetime] = None
|
|
self.last_heartbeat_time: Optional[datetime] = None
|
|
|
|
# Aggregation counters (since last heartbeat)
|
|
self.interval_working_s = 0.0
|
|
self.interval_standby_s = 0.0
|
|
self.interval_power_samples = []
|
|
self.interval_state_changes = 0
|
|
|
|
# Total counters (entire session)
|
|
self.total_working_s = 0.0
|
|
self.total_standby_s = 0.0
|
|
|
|
logger.info(f"SessionDetector initialized device={device_id} machine={machine_name} "
|
|
f"standby={standby_threshold_w}W working={working_threshold_w}W "
|
|
f"heartbeat={heartbeat_interval_s}s")
|
|
|
|
def process_power_measurement(self, power_w: float, timestamp: datetime):
|
|
"""
|
|
Process a power measurement.
|
|
|
|
Args:
|
|
power_w: Power in watts
|
|
timestamp: Measurement timestamp
|
|
"""
|
|
self.last_message_time = timestamp
|
|
|
|
# State machine
|
|
if self.state == 'idle':
|
|
self._handle_idle(power_w, timestamp)
|
|
elif self.state == 'starting':
|
|
self._handle_starting(power_w, timestamp)
|
|
elif self.state == 'standby':
|
|
self._handle_standby(power_w, timestamp)
|
|
elif self.state == 'working':
|
|
self._handle_working(power_w, timestamp)
|
|
elif self.state == 'stopping':
|
|
self._handle_stopping(power_w, timestamp)
|
|
|
|
# Collect power sample for aggregation
|
|
if self.current_session_id:
|
|
self.interval_power_samples.append(power_w)
|
|
|
|
# Check for heartbeat emission
|
|
if self.current_session_id and self.last_heartbeat_time:
|
|
time_since_heartbeat = (timestamp - self.last_heartbeat_time).total_seconds()
|
|
if time_since_heartbeat >= self.heartbeat_interval_s:
|
|
self._emit_heartbeat(timestamp)
|
|
|
|
def _handle_idle(self, power_w: float, timestamp: datetime):
|
|
"""IDLE State: Wait for power above standby threshold."""
|
|
if power_w > self.standby_threshold_w:
|
|
self._transition_to('starting', timestamp)
|
|
|
|
def _handle_starting(self, power_w: float, timestamp: datetime):
|
|
"""STARTING State: Debounce period before confirming session start."""
|
|
if power_w < self.standby_threshold_w:
|
|
logger.info(f"device={self.device_id} power_dropped_during_starting back_to=idle")
|
|
self._transition_to('idle', timestamp)
|
|
return
|
|
|
|
time_in_state = (timestamp - self.state_entered_at).total_seconds()
|
|
if time_in_state >= self.start_debounce_s:
|
|
self._start_session(power_w, timestamp)
|
|
self._transition_to('standby', timestamp)
|
|
|
|
def _handle_standby(self, power_w: float, timestamp: datetime):
|
|
"""STANDBY State: Session running at low power."""
|
|
# Accumulate standby time
|
|
if self.state_entered_at:
|
|
time_in_state = (timestamp - self.state_entered_at).total_seconds()
|
|
self.interval_standby_s += time_in_state
|
|
self.total_standby_s += time_in_state
|
|
|
|
if power_w < self.standby_threshold_w:
|
|
self._transition_to('stopping', timestamp)
|
|
elif power_w > self.working_threshold_w:
|
|
self._transition_to('working', timestamp)
|
|
else:
|
|
# Stay in standby, update state entry time for next increment
|
|
self.state_entered_at = timestamp
|
|
|
|
def _handle_working(self, power_w: float, timestamp: datetime):
|
|
"""WORKING State: Session running at high power."""
|
|
# Accumulate working time
|
|
if self.state_entered_at:
|
|
time_in_state = (timestamp - self.state_entered_at).total_seconds()
|
|
self.interval_working_s += time_in_state
|
|
self.total_working_s += time_in_state
|
|
|
|
if power_w < self.standby_threshold_w:
|
|
self._transition_to('stopping', timestamp)
|
|
elif power_w < self.working_threshold_w:
|
|
self._transition_to('standby', timestamp)
|
|
else:
|
|
# Stay in working, update state entry time for next increment
|
|
self.state_entered_at = timestamp
|
|
|
|
def _handle_stopping(self, power_w: float, timestamp: datetime):
|
|
"""STOPPING State: Debounce period before ending session."""
|
|
if power_w > self.standby_threshold_w:
|
|
if power_w > self.working_threshold_w:
|
|
logger.info(f"device={self.device_id} power_resumed_during_stopping back_to=working")
|
|
self._transition_to('working', timestamp)
|
|
else:
|
|
logger.info(f"device={self.device_id} power_resumed_during_stopping back_to=standby")
|
|
self._transition_to('standby', timestamp)
|
|
return
|
|
|
|
time_in_state = (timestamp - self.state_entered_at).total_seconds()
|
|
if time_in_state >= self.stop_debounce_s:
|
|
self._end_session('normal', timestamp)
|
|
self._transition_to('idle', timestamp)
|
|
|
|
def _transition_to(self, new_state: str, timestamp: datetime):
|
|
"""Transition to a new state."""
|
|
old_state = self.state
|
|
self.state = new_state
|
|
self.state_entered_at = timestamp
|
|
|
|
if self.current_session_id:
|
|
self.interval_state_changes += 1
|
|
|
|
logger.info(f"device={self.device_id} state_transition from={old_state} to={new_state}")
|
|
|
|
def _start_session(self, power_w: float, timestamp: datetime):
|
|
"""Start a new session."""
|
|
self.current_session_id = str(uuid.uuid4())
|
|
self.session_start_time = timestamp
|
|
self.last_heartbeat_time = timestamp
|
|
|
|
# Reset counters
|
|
self.interval_working_s = 0.0
|
|
self.interval_standby_s = 0.0
|
|
self.interval_power_samples = []
|
|
self.interval_state_changes = 0
|
|
self.total_working_s = 0.0
|
|
self.total_standby_s = 0.0
|
|
|
|
logger.info(f"device={self.device_id} session_started session_id={self.current_session_id[:8]} power={power_w}W")
|
|
|
|
if self.event_callback:
|
|
self.event_callback({
|
|
'event_uid': str(uuid.uuid4()),
|
|
'event_type': 'session_started',
|
|
'device_id': self.device_id,
|
|
'session_id': self.current_session_id,
|
|
'machine_name': self.machine_name,
|
|
'timestamp': timestamp.isoformat(),
|
|
'payload': {
|
|
'start_power_w': power_w,
|
|
'start_state': 'standby'
|
|
}
|
|
})
|
|
|
|
def _emit_heartbeat(self, timestamp: datetime):
|
|
"""Emit aggregated heartbeat event."""
|
|
if not self.current_session_id:
|
|
return
|
|
|
|
# Calculate average power
|
|
avg_power = sum(self.interval_power_samples) / len(self.interval_power_samples) if self.interval_power_samples else 0
|
|
|
|
logger.info(f"device={self.device_id} session_heartbeat session_id={self.current_session_id[:8]} "
|
|
f"interval_working={self.interval_working_s:.0f}s interval_standby={self.interval_standby_s:.0f}s "
|
|
f"avg_power={avg_power:.1f}W state_changes={self.interval_state_changes}")
|
|
|
|
if self.event_callback:
|
|
self.event_callback({
|
|
'event_uid': str(uuid.uuid4()),
|
|
'event_type': 'session_heartbeat',
|
|
'device_id': self.device_id,
|
|
'session_id': self.current_session_id,
|
|
'machine_name': self.machine_name,
|
|
'timestamp': timestamp.isoformat(),
|
|
'payload': {
|
|
'interval_working_s': round(self.interval_working_s, 1),
|
|
'interval_standby_s': round(self.interval_standby_s, 1),
|
|
'current_state': self.state,
|
|
'avg_power_w': round(avg_power, 1),
|
|
'state_change_count': self.interval_state_changes,
|
|
'total_working_s': round(self.total_working_s, 1),
|
|
'total_standby_s': round(self.total_standby_s, 1)
|
|
}
|
|
})
|
|
|
|
# Reset interval counters
|
|
self.interval_working_s = 0.0
|
|
self.interval_standby_s = 0.0
|
|
self.interval_power_samples = []
|
|
self.interval_state_changes = 0
|
|
self.last_heartbeat_time = timestamp
|
|
|
|
def _end_session(self, reason: str, timestamp: datetime):
|
|
"""End the current session."""
|
|
if not self.current_session_id:
|
|
return
|
|
|
|
# Emit final heartbeat if there's accumulated data
|
|
if self.interval_working_s > 0 or self.interval_standby_s > 0:
|
|
self._emit_heartbeat(timestamp)
|
|
|
|
total_duration = (timestamp - self.session_start_time).total_seconds() if self.session_start_time else 0
|
|
|
|
logger.info(f"device={self.device_id} session_ended session_id={self.current_session_id[:8]} "
|
|
f"reason={reason} duration={total_duration:.0f}s "
|
|
f"total_working={self.total_working_s:.0f}s total_standby={self.total_standby_s:.0f}s")
|
|
|
|
if self.event_callback:
|
|
self.event_callback({
|
|
'event_uid': str(uuid.uuid4()),
|
|
'event_type': 'session_ended' if reason == 'normal' else 'session_timeout',
|
|
'device_id': self.device_id,
|
|
'session_id': self.current_session_id,
|
|
'machine_name': self.machine_name,
|
|
'timestamp': timestamp.isoformat(),
|
|
'payload': {
|
|
'end_reason': reason,
|
|
'total_duration_s': round(total_duration, 1),
|
|
'total_working_s': round(self.total_working_s, 1),
|
|
'total_standby_s': round(self.total_standby_s, 1)
|
|
}
|
|
})
|
|
|
|
self.current_session_id = None
|
|
|
|
def check_timeout(self, current_time: datetime):
|
|
"""Check if session timed out (no messages for too long)."""
|
|
if not self.current_session_id or not self.last_message_time:
|
|
return
|
|
|
|
time_since_last = (current_time - self.last_message_time).total_seconds()
|
|
if time_since_last > self.message_timeout_s:
|
|
logger.warning(f"device={self.device_id} session_timeout no_messages_for={time_since_last:.0f}s")
|
|
self._end_session('timeout', current_time)
|
|
self._transition_to('idle', current_time)
|