docs: refactor docs structure and tighten assistant instruction policy
shrink root README into a landing page with a docs map and focused contributor guidance add TV_POWER_RUNBOOK as the canonical TV power rollout and canary runbook add CHANGELOG and move project history out of README-style docs refactor src README into a developer-focused guide (architecture, runtime files, MQTT, debugging) prune redundant older HDMI docs and keep a canonical HDMI_CEC_SETUP path update copilot instructions to a high-signal policy format with strict anti-shadow-README design rules align references across docs to current files, scripts, and TV power behavior
This commit is contained in:
314
src/simclient.py
314
src/simclient.py
@@ -147,6 +147,9 @@ CLIENT_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "config", "client
|
||||
# Screenshot IPC (written by display_manager, polled by simclient)
|
||||
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "screenshots")
|
||||
SCREENSHOT_META_FILE = os.path.join(SCREENSHOT_DIR, "meta.json")
|
||||
POWER_CONTROL_MODE = os.getenv("POWER_CONTROL_MODE", "local").strip().lower()
|
||||
POWER_INTENT_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_intent_state.json")
|
||||
POWER_STATE_FILE = os.path.join(os.path.dirname(__file__), "power_state.json")
|
||||
|
||||
|
||||
discovered = False
|
||||
@@ -237,6 +240,127 @@ def is_empty_event(event_data):
|
||||
return False
|
||||
|
||||
|
||||
def _parse_utc_iso(value: str):
|
||||
"""Parse ISO8601 timestamp with optional trailing Z into UTC-aware datetime."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("timestamp must be a non-empty string")
|
||||
normalized = value.strip()
|
||||
if normalized.endswith('Z'):
|
||||
normalized = normalized[:-1] + '+00:00'
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_power_intent_payload(payload, expected_group_id=None):
|
||||
"""Validate frozen TV power intent contract v1 payload.
|
||||
|
||||
Returns tuple: (is_valid, result_dict, error_message)
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return False, None, "payload must be an object"
|
||||
|
||||
required_fields = (
|
||||
"schema_version", "intent_id", "group_id", "desired_state", "reason",
|
||||
"issued_at", "expires_at", "poll_interval_sec", "active_event_ids",
|
||||
"event_window_start", "event_window_end"
|
||||
)
|
||||
for field in required_fields:
|
||||
if field not in payload:
|
||||
return False, None, f"missing required field: {field}"
|
||||
|
||||
if payload.get("schema_version") != "1.0":
|
||||
return False, None, f"unsupported schema_version: {payload.get('schema_version')}"
|
||||
|
||||
desired_state = payload.get("desired_state")
|
||||
if desired_state not in ("on", "off"):
|
||||
return False, None, f"invalid desired_state: {desired_state}"
|
||||
|
||||
reason = payload.get("reason")
|
||||
if reason not in ("active_event", "no_active_event"):
|
||||
return False, None, f"invalid reason: {reason}"
|
||||
|
||||
intent_id = payload.get("intent_id")
|
||||
if not isinstance(intent_id, str) or not intent_id.strip():
|
||||
return False, None, "intent_id must be a non-empty string"
|
||||
|
||||
try:
|
||||
group_id = int(payload.get("group_id"))
|
||||
except Exception:
|
||||
return False, None, f"invalid group_id: {payload.get('group_id')}"
|
||||
|
||||
if expected_group_id is not None:
|
||||
try:
|
||||
expected_group_id_int = int(expected_group_id)
|
||||
except Exception:
|
||||
expected_group_id_int = None
|
||||
if expected_group_id_int is not None and expected_group_id_int != group_id:
|
||||
return False, None, f"group_id mismatch: payload={group_id} expected={expected_group_id_int}"
|
||||
|
||||
try:
|
||||
issued_at = _parse_utc_iso(payload.get("issued_at"))
|
||||
expires_at = _parse_utc_iso(payload.get("expires_at"))
|
||||
except Exception as e:
|
||||
return False, None, f"invalid timestamp: {e}"
|
||||
|
||||
if expires_at <= issued_at:
|
||||
return False, None, "expires_at must be later than issued_at"
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
return False, None, "intent expired"
|
||||
|
||||
try:
|
||||
poll_interval_sec = int(payload.get("poll_interval_sec"))
|
||||
except Exception:
|
||||
return False, None, f"invalid poll_interval_sec: {payload.get('poll_interval_sec')}"
|
||||
if poll_interval_sec <= 0:
|
||||
return False, None, "poll_interval_sec must be > 0"
|
||||
|
||||
active_event_ids = payload.get("active_event_ids")
|
||||
if not isinstance(active_event_ids, list):
|
||||
return False, None, "active_event_ids must be a list"
|
||||
normalized_event_ids = []
|
||||
for item in active_event_ids:
|
||||
try:
|
||||
normalized_event_ids.append(int(item))
|
||||
except Exception:
|
||||
return False, None, f"invalid active_event_id value: {item}"
|
||||
|
||||
for field in ("event_window_start", "event_window_end"):
|
||||
value = payload.get(field)
|
||||
if value is not None:
|
||||
try:
|
||||
_parse_utc_iso(value)
|
||||
except Exception as e:
|
||||
return False, None, f"invalid {field}: {e}"
|
||||
|
||||
normalized = {
|
||||
"schema_version": "1.0",
|
||||
"intent_id": intent_id.strip(),
|
||||
"group_id": group_id,
|
||||
"desired_state": desired_state,
|
||||
"reason": reason,
|
||||
"issued_at": payload.get("issued_at"),
|
||||
"expires_at": payload.get("expires_at"),
|
||||
"poll_interval_sec": poll_interval_sec,
|
||||
"active_event_ids": normalized_event_ids,
|
||||
"event_window_start": payload.get("event_window_start"),
|
||||
"event_window_end": payload.get("event_window_end"),
|
||||
}
|
||||
return True, normalized, None
|
||||
|
||||
|
||||
def write_power_intent_state(data):
|
||||
"""Atomically write power intent state for display_manager consumption."""
|
||||
try:
|
||||
tmp_path = POWER_INTENT_STATE_FILE + ".tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_path, POWER_INTENT_STATE_FILE)
|
||||
except Exception as e:
|
||||
logging.error(f"Error writing power intent state: {e}")
|
||||
|
||||
|
||||
def on_message(client, userdata, msg, properties=None):
|
||||
global discovered
|
||||
logging.info(f"Received: {msg.topic} {msg.payload.decode()}")
|
||||
@@ -563,6 +687,64 @@ def read_health_state():
|
||||
return None
|
||||
|
||||
|
||||
def read_power_state():
|
||||
"""Read last power action state produced by display_manager."""
|
||||
try:
|
||||
if not os.path.exists(POWER_STATE_FILE):
|
||||
return None
|
||||
with open(POWER_STATE_FILE, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not read power state file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def publish_power_state_message(client, client_id, power_state: dict):
|
||||
"""Publish power action telemetry to MQTT (best effort)."""
|
||||
try:
|
||||
if not isinstance(power_state, dict):
|
||||
return
|
||||
|
||||
payload = dict(power_state)
|
||||
payload["client_id"] = client_id
|
||||
payload.setdefault("reported_at", datetime.now(timezone.utc).isoformat())
|
||||
|
||||
topic = f"infoscreen/{client_id}/power/state"
|
||||
res = client.publish(topic, json.dumps(payload), qos=0)
|
||||
if res.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||
p = payload.get("power", {})
|
||||
logging.info(
|
||||
"Power state published: state=%s source=%s result=%s",
|
||||
p.get("applied_state"),
|
||||
p.get("source"),
|
||||
p.get("result"),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Could not publish power state: {e}")
|
||||
|
||||
|
||||
def power_state_service_thread(client, client_id):
|
||||
"""Background publisher for power action state changes."""
|
||||
logging.info("Power state service started")
|
||||
last_mtime = None
|
||||
while True:
|
||||
try:
|
||||
time.sleep(1)
|
||||
if not os.path.exists(POWER_STATE_FILE):
|
||||
continue
|
||||
mtime = os.path.getmtime(POWER_STATE_FILE)
|
||||
if last_mtime is not None and mtime <= last_mtime:
|
||||
continue
|
||||
last_mtime = mtime
|
||||
|
||||
state = read_power_state()
|
||||
if state:
|
||||
publish_power_state_message(client, client_id, state)
|
||||
except Exception as e:
|
||||
logging.debug(f"Power state service error: {e}")
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def save_client_settings(settings_data):
|
||||
"""Persist dashboard-managed client settings for the display manager."""
|
||||
try:
|
||||
@@ -822,6 +1004,9 @@ def main():
|
||||
group_id_path = os.path.join(os.path.dirname(__file__), "config", "last_group_id.txt")
|
||||
current_group_id = load_last_group_id(group_id_path)
|
||||
event_topic = None
|
||||
power_intent_topic = None
|
||||
last_power_intent_id = None
|
||||
last_power_issued_at = None
|
||||
|
||||
# paho-mqtt v2: opt into latest callback API to avoid deprecation warnings.
|
||||
client_kwargs = {"protocol": mqtt.MQTTv311}
|
||||
@@ -880,6 +1065,25 @@ def main():
|
||||
if group_changed:
|
||||
current_group_id = new_group_id
|
||||
save_last_group_id(group_id_path, new_group_id)
|
||||
|
||||
def subscribe_power_intent_topic(new_group_id):
|
||||
nonlocal power_intent_topic
|
||||
|
||||
if POWER_CONTROL_MODE not in ("hybrid", "mqtt"):
|
||||
return
|
||||
|
||||
new_topic = f"infoscreen/groups/{new_group_id}/power/intent"
|
||||
if power_intent_topic == new_topic:
|
||||
logging.info(f"Power intent topic already subscribed: {power_intent_topic}")
|
||||
return
|
||||
|
||||
if power_intent_topic:
|
||||
client.unsubscribe(power_intent_topic)
|
||||
logging.info(f"Unsubscribed from power intent topic: {power_intent_topic}")
|
||||
|
||||
power_intent_topic = new_topic
|
||||
client.subscribe(power_intent_topic, qos=1)
|
||||
logging.info(f"Subscribed to power intent topic: {power_intent_topic}")
|
||||
|
||||
# on_connect callback: Subscribe to all topics after connection is established
|
||||
def on_connect(client, userdata, flags, rc, properties=None):
|
||||
@@ -926,6 +1130,9 @@ def main():
|
||||
nonlocal event_topic
|
||||
event_topic = None # force re-subscribe regardless of previous state
|
||||
subscribe_event_topic(current_group_id)
|
||||
nonlocal power_intent_topic
|
||||
power_intent_topic = None
|
||||
subscribe_power_intent_topic(current_group_id)
|
||||
|
||||
# Send discovery message after reconnection to re-register with server
|
||||
if is_reconnect:
|
||||
@@ -1020,11 +1227,95 @@ def main():
|
||||
logging.info(f"group_id unchanged: {new_group_id}, ensuring event topic is subscribed")
|
||||
# Always call subscribe_event_topic to ensure subscription
|
||||
subscribe_event_topic(new_group_id)
|
||||
subscribe_power_intent_topic(new_group_id)
|
||||
else:
|
||||
logging.warning("Empty group_id received!")
|
||||
client.message_callback_add(group_id_topic, on_group_id_message)
|
||||
logging.info(f"Current group_id at start: {current_group_id if current_group_id else 'none'}")
|
||||
|
||||
def on_power_intent_message(client, userdata, msg, properties=None):
|
||||
nonlocal last_power_intent_id, last_power_issued_at
|
||||
|
||||
payload_text = msg.payload.decode().strip()
|
||||
received_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# A retained null-message clears the topic and arrives as an empty payload.
|
||||
if not payload_text:
|
||||
logging.info("Power intent retained message cleared (empty payload)")
|
||||
write_power_intent_state({
|
||||
"valid": False,
|
||||
"mode": POWER_CONTROL_MODE,
|
||||
"error": "retained_cleared",
|
||||
"received_at": received_at,
|
||||
"topic": msg.topic,
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(payload_text)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(f"Invalid power intent JSON: {e}")
|
||||
write_power_intent_state({
|
||||
"valid": False,
|
||||
"mode": POWER_CONTROL_MODE,
|
||||
"error": f"invalid_json: {e}",
|
||||
"received_at": received_at,
|
||||
"topic": msg.topic,
|
||||
})
|
||||
return
|
||||
|
||||
is_valid, normalized, error = validate_power_intent_payload(payload, expected_group_id=current_group_id)
|
||||
if not is_valid:
|
||||
logging.warning(f"Rejected power intent: {error}")
|
||||
write_power_intent_state({
|
||||
"valid": False,
|
||||
"mode": POWER_CONTROL_MODE,
|
||||
"error": error,
|
||||
"received_at": received_at,
|
||||
"topic": msg.topic,
|
||||
})
|
||||
return
|
||||
|
||||
try:
|
||||
issued_dt = _parse_utc_iso(normalized["issued_at"])
|
||||
except Exception:
|
||||
issued_dt = None
|
||||
|
||||
if last_power_issued_at and issued_dt and issued_dt < last_power_issued_at:
|
||||
logging.warning(
|
||||
f"Rejected out-of-order power intent {normalized['intent_id']} issued_at={normalized['issued_at']}"
|
||||
)
|
||||
write_power_intent_state({
|
||||
"valid": False,
|
||||
"mode": POWER_CONTROL_MODE,
|
||||
"error": "out_of_order_intent",
|
||||
"received_at": received_at,
|
||||
"topic": msg.topic,
|
||||
})
|
||||
return
|
||||
|
||||
duplicate_intent_id = normalized["intent_id"] == last_power_intent_id
|
||||
if issued_dt:
|
||||
last_power_issued_at = issued_dt
|
||||
last_power_intent_id = normalized["intent_id"]
|
||||
|
||||
logging.info(
|
||||
"Power intent accepted: id=%s desired_state=%s reason=%s expires_at=%s duplicate=%s",
|
||||
normalized["intent_id"],
|
||||
normalized["desired_state"],
|
||||
normalized["reason"],
|
||||
normalized["expires_at"],
|
||||
duplicate_intent_id,
|
||||
)
|
||||
write_power_intent_state({
|
||||
"valid": True,
|
||||
"mode": POWER_CONTROL_MODE,
|
||||
"received_at": received_at,
|
||||
"topic": msg.topic,
|
||||
"duplicate_intent_id": duplicate_intent_id,
|
||||
"payload": normalized,
|
||||
})
|
||||
|
||||
config_topic = f"infoscreen/{client_id}/config"
|
||||
def on_config_message(client, userdata, msg, properties=None):
|
||||
payload = msg.payload.decode().strip()
|
||||
@@ -1047,6 +1338,21 @@ def main():
|
||||
|
||||
client.message_callback_add(config_topic, on_config_message)
|
||||
|
||||
if POWER_CONTROL_MODE in ("hybrid", "mqtt"):
|
||||
if current_group_id:
|
||||
subscribe_power_intent_topic(current_group_id)
|
||||
else:
|
||||
logging.info("Power control mode active but no group_id yet; waiting for group assignment")
|
||||
|
||||
def on_power_intent_dispatch(client, userdata, msg, properties=None):
|
||||
on_power_intent_message(client, userdata, msg, properties)
|
||||
|
||||
# Register a generic callback so topic changes on group switch do not require re-registration.
|
||||
client.message_callback_add("infoscreen/groups/+/power/intent", on_power_intent_dispatch)
|
||||
logging.info(f"Power control mode active: {POWER_CONTROL_MODE}")
|
||||
else:
|
||||
logging.info(f"Power control mode is local; MQTT power intents disabled")
|
||||
|
||||
# Discovery-Phase: Sende Discovery bis ACK empfangen
|
||||
# The loop is already started, just wait and send discovery messages
|
||||
discovery_attempts = 0
|
||||
@@ -1079,6 +1385,14 @@ def main():
|
||||
screenshot_thread.start()
|
||||
logging.info("Screenshot service thread started")
|
||||
|
||||
power_state_thread = threading.Thread(
|
||||
target=power_state_service_thread,
|
||||
args=(client, client_id),
|
||||
daemon=True,
|
||||
)
|
||||
power_state_thread.start()
|
||||
logging.info("Power state service thread started")
|
||||
|
||||
# Heartbeat-Loop with connection state monitoring
|
||||
last_heartbeat = 0
|
||||
logging.info("Entering heartbeat loop (network loop already running in background thread)")
|
||||
|
||||
Reference in New Issue
Block a user