feat(tv-power): implement server PR1 with tests and documentation

This commit is contained in:
2026-04-01 08:07:18 +00:00
parent b5f5f30005
commit 3fc7d33e43
15 changed files with 1997 additions and 3 deletions

View File

@@ -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 = {