feat(tv-power): implement server PR1 with tests and documentation
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from sqlalchemy.orm import sessionmaker, joinedload
|
||||
from sqlalchemy import create_engine, or_, and_, text
|
||||
@@ -184,6 +186,131 @@ def get_system_setting_value(key: str, default: str | None = None) -> str | None
|
||||
session.close()
|
||||
|
||||
|
||||
def _parse_utc_datetime(value):
|
||||
"""Parse datetime-like values and normalize to timezone-aware UTC."""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
else:
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _normalize_group_id(group_id):
|
||||
try:
|
||||
return int(group_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _event_range_from_dict(event):
|
||||
start = _parse_utc_datetime(event.get("start"))
|
||||
end = _parse_utc_datetime(event.get("end"))
|
||||
if start is None or end is None or end <= start:
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def _merge_ranges(ranges, adjacency_seconds=0):
|
||||
"""Merge overlapping or adjacent [start, end] ranges."""
|
||||
if not ranges:
|
||||
return []
|
||||
|
||||
ranges_sorted = sorted(ranges, key=lambda r: (r[0], r[1]))
|
||||
merged = [ranges_sorted[0]]
|
||||
adjacency_delta = max(0, int(adjacency_seconds))
|
||||
|
||||
for current_start, current_end in ranges_sorted[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if current_start <= last_end or (current_start - last_end).total_seconds() <= adjacency_delta:
|
||||
if current_end > last_end:
|
||||
merged[-1] = (last_start, current_end)
|
||||
else:
|
||||
merged.append((current_start, current_end))
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def compute_group_power_intent_basis(events, group_id, now_utc=None, adjacency_seconds=0):
|
||||
"""Return pure, deterministic power intent basis for one group at a point in time.
|
||||
|
||||
The returned mapping intentionally excludes volatile fields such as intent_id,
|
||||
issued_at and expires_at.
|
||||
"""
|
||||
normalized_gid = _normalize_group_id(group_id)
|
||||
effective_now = _parse_utc_datetime(now_utc) or datetime.now(timezone.utc)
|
||||
|
||||
ranges = []
|
||||
active_event_ids = []
|
||||
for event in events or []:
|
||||
if _normalize_group_id(event.get("group_id")) != normalized_gid:
|
||||
continue
|
||||
parsed_range = _event_range_from_dict(event)
|
||||
if parsed_range is None:
|
||||
continue
|
||||
|
||||
start, end = parsed_range
|
||||
ranges.append((start, end))
|
||||
if start <= effective_now < end:
|
||||
event_id = event.get("id")
|
||||
if event_id is not None:
|
||||
active_event_ids.append(event_id)
|
||||
|
||||
merged_ranges = _merge_ranges(ranges, adjacency_seconds=adjacency_seconds)
|
||||
|
||||
active_window_start = None
|
||||
active_window_end = None
|
||||
for window_start, window_end in merged_ranges:
|
||||
if window_start <= effective_now < window_end:
|
||||
active_window_start = window_start
|
||||
active_window_end = window_end
|
||||
break
|
||||
|
||||
desired_state = "on" if active_window_start is not None else "off"
|
||||
reason = "active_event" if desired_state == "on" else "no_active_event"
|
||||
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"group_id": normalized_gid,
|
||||
"desired_state": desired_state,
|
||||
"reason": reason,
|
||||
"poll_interval_sec": None,
|
||||
"event_window_start": active_window_start.isoformat().replace("+00:00", "Z") if active_window_start else None,
|
||||
"event_window_end": active_window_end.isoformat().replace("+00:00", "Z") if active_window_end else None,
|
||||
"active_event_ids": sorted(set(active_event_ids)),
|
||||
}
|
||||
|
||||
|
||||
def build_group_power_intent_body(intent_basis, poll_interval_sec):
|
||||
"""Build deterministic payload body (without intent_id/issued_at/expires_at)."""
|
||||
body = {
|
||||
"schema_version": intent_basis.get("schema_version", "1.0"),
|
||||
"group_id": intent_basis.get("group_id"),
|
||||
"desired_state": intent_basis.get("desired_state", "off"),
|
||||
"reason": intent_basis.get("reason", "no_active_event"),
|
||||
"poll_interval_sec": int(poll_interval_sec),
|
||||
"event_window_start": intent_basis.get("event_window_start"),
|
||||
"event_window_end": intent_basis.get("event_window_end"),
|
||||
"active_event_ids": list(intent_basis.get("active_event_ids", [])),
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
def compute_group_power_intent_fingerprint(intent_body):
|
||||
"""Create a stable hash for semantic transition detection."""
|
||||
canonical_json = json.dumps(intent_body, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def format_event_with_media(event):
|
||||
"""Transform Event + EventMedia into client-expected format"""
|
||||
event_dict = {
|
||||
|
||||
Reference in New Issue
Block a user