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:
RobbStarkAustria
2026-04-01 10:01:58 +02:00
parent fb0980aa88
commit 82f43f75ba
20 changed files with 2228 additions and 2267 deletions

View File

@@ -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)")