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:
246
scripts/test-power-intent.sh
Executable file
246
scripts/test-power-intent.sh
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/bin/bash
|
||||
# Test TV power intent MQTT message flow (Phase 1 contract v1)
|
||||
# Requires: mosquitto_pub / mosquitto_sub
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ── Load .env ────────────────────────────────────────────────────────────────
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
# Strip inline comments and surrounding whitespace before export.
|
||||
while IFS='=' read -r key value; do
|
||||
key="${key//[$'\t\r\n']}"
|
||||
key="$(echo "$key" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
# Skip comments/empty lines/invalid keys
|
||||
[[ -z "$key" ]] && continue
|
||||
[[ "$key" =~ ^# ]] && continue
|
||||
[[ "$key" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
|
||||
|
||||
value="${value%%#*}" # strip inline comments
|
||||
value="${value//[$'\t\r\n']}"
|
||||
value="$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
|
||||
export "$key=$value"
|
||||
done < "$ENV_FILE"
|
||||
fi
|
||||
|
||||
BROKER="${MQTT_BROKER:-localhost}"
|
||||
PORT="${MQTT_PORT:-1883}"
|
||||
|
||||
# ── Read runtime IDs ─────────────────────────────────────────────────────────
|
||||
GROUP_ID_FILE="$PROJECT_ROOT/src/config/last_group_id.txt"
|
||||
CLIENT_UUID_FILE="$PROJECT_ROOT/src/config/client_uuid.txt"
|
||||
GROUP_ID=""
|
||||
CLIENT_UUID=""
|
||||
[ -f "$GROUP_ID_FILE" ] && GROUP_ID="$(cat "$GROUP_ID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
[ -f "$CLIENT_UUID_FILE" ] && CLIENT_UUID="$(cat "$CLIENT_UUID_FILE" 2>/dev/null | tr -d '[:space:]')"
|
||||
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo -e "${BLUE}TV Power Intent Test (Phase 1 Contract)${NC}"
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo " Broker : $BROKER:$PORT"
|
||||
echo " Group : ${GROUP_ID:-<not assigned yet>}"
|
||||
echo " Client : ${CLIENT_UUID:-<unknown>}"
|
||||
echo ""
|
||||
|
||||
# ── Check tools ──────────────────────────────────────────────────────────────
|
||||
if ! command -v mosquitto_pub &>/dev/null; then
|
||||
echo -e "${RED}mosquitto_pub not found. Install with: sudo apt-get install mosquitto-clients${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
now_iso() { date -u +"%Y-%m-%dT%H:%M:%S.000Z"; }
|
||||
|
||||
# expires_at = now + <seconds>
|
||||
expires_iso() {
|
||||
local secs="${1:-90}"
|
||||
date -u -d "@$(( $(date +%s) + secs ))" +"%Y-%m-%dT%H:%M:%S.000Z"
|
||||
}
|
||||
|
||||
group_topic() {
|
||||
echo "infoscreen/groups/${GROUP_ID}/power/intent"
|
||||
}
|
||||
|
||||
publish_intent() {
|
||||
local state="$1"
|
||||
local reason="$2"
|
||||
local issued="${3:-$(now_iso)}"
|
||||
local expires="${4:-$(expires_iso 90)}"
|
||||
local intent_id
|
||||
intent_id="$(python3 -c 'import uuid; print(uuid.uuid4())')"
|
||||
local topic
|
||||
topic="$(group_topic)"
|
||||
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id found. Subscribe a client and assign a group first.${NC}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"intent_id": "$intent_id",
|
||||
"group_id": $GROUP_ID,
|
||||
"desired_state": "$state",
|
||||
"reason": "$reason",
|
||||
"issued_at": "$issued",
|
||||
"expires_at": "$expires",
|
||||
"poll_interval_sec": 15,
|
||||
"active_event_ids": [$([ "$state" = "on" ] && echo "1" || echo "")],
|
||||
"event_window_start": $([ "$state" = "on" ] && echo "\"$(now_iso)\"" || echo "null"),
|
||||
"event_window_end": $([ "$state" = "on" ] && echo "\"$(expires_iso 3600)\"" || echo "null")
|
||||
}
|
||||
EOF
|
||||
)
|
||||
echo -e "${YELLOW}Publishing to: $topic${NC}"
|
||||
echo "$payload" | python3 -m json.tool 2>/dev/null || echo "$payload"
|
||||
echo ""
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" -t "$topic" -q 1 --retain -m "$payload"
|
||||
echo -e "${GREEN}Published (retained, QoS 1)${NC}"
|
||||
echo "intent_id: $intent_id"
|
||||
}
|
||||
|
||||
clear_intent() {
|
||||
local topic
|
||||
topic="$(group_topic)"
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id found.${NC}"
|
||||
return 1
|
||||
fi
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" -t "$topic" -q 1 --retain --null-message
|
||||
echo -e "${GREEN}Retained intent cleared from broker${NC}"
|
||||
}
|
||||
|
||||
show_state_files() {
|
||||
echo ""
|
||||
local intent_file="$PROJECT_ROOT/src/power_intent_state.json"
|
||||
local state_file="$PROJECT_ROOT/src/power_state.json"
|
||||
local health_file="$PROJECT_ROOT/src/current_process_health.json"
|
||||
|
||||
for f in "$intent_file" "$state_file" "$health_file"; do
|
||||
local label
|
||||
label="$(basename "$f")"
|
||||
if [ -f "$f" ]; then
|
||||
echo -e "${BLUE}── $label ──────────────────${NC}"
|
||||
python3 -m json.tool "$f" 2>/dev/null || cat "$f"
|
||||
else
|
||||
echo -e "${YELLOW}$label not found${NC}"
|
||||
fi
|
||||
echo ""
|
||||
done
|
||||
}
|
||||
|
||||
watch_logs() {
|
||||
echo -e "${YELLOW}Following power-related log entries (Ctrl-C to stop)...${NC}"
|
||||
local dm_log="$PROJECT_ROOT/logs/display_manager.log"
|
||||
local sc_log="$PROJECT_ROOT/src/simclient.log"
|
||||
|
||||
if [ -f "$dm_log" ] && [ -f "$sc_log" ]; then
|
||||
tail -f "$dm_log" "$sc_log" | grep --line-buffered -i \
|
||||
-E "(power|intent|cec|turn|desired_state|mqtt_intent|local_fallback|POWER)"
|
||||
elif [ -f "$dm_log" ]; then
|
||||
tail -f "$dm_log" | grep --line-buffered -i \
|
||||
-E "(power|intent|cec|turn|desired_state|mqtt_intent|local_fallback|POWER)"
|
||||
else
|
||||
echo -e "${RED}No log files found. Have both processes run at least once?${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
subscribe_power_state() {
|
||||
if [ -z "$CLIENT_UUID" ]; then
|
||||
echo -e "${RED}No client_uuid found.${NC}"
|
||||
return 1
|
||||
fi
|
||||
local topic="infoscreen/${CLIENT_UUID}/power/state"
|
||||
echo -e "${YELLOW}Subscribing to: $topic${NC}"
|
||||
echo "(Ctrl-C to stop)"
|
||||
echo ""
|
||||
mosquitto_sub -h "$BROKER" -p "$PORT" -t "$topic" | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
print(json.dumps(json.loads(line), indent=2))
|
||||
except Exception:
|
||||
print(line)
|
||||
"
|
||||
}
|
||||
|
||||
# ── Menu ─────────────────────────────────────────────────────────────────────
|
||||
while true; do
|
||||
echo -e "${BLUE}================================================${NC}"
|
||||
echo "Choose a test:"
|
||||
echo " 1) Publish ON intent (valid 90s, group ${GROUP_ID:-?})"
|
||||
echo " 2) Publish OFF intent (valid 90s, group ${GROUP_ID:-?})"
|
||||
echo " 3) Publish stale intent (already expired) — expect rejection"
|
||||
echo " 4) Publish malformed intent (missing fields) — expect rejection"
|
||||
echo " 5) Clear retained intent from broker (sends empty retained payload)"
|
||||
echo " 6) Show power_intent_state / power_state / health JSON files"
|
||||
echo " 7) Follow power-related log entries (display_manager + simclient)"
|
||||
echo " 8) Subscribe to infoscreen/{client}/power/state topic"
|
||||
echo " q) Quit"
|
||||
echo ""
|
||||
read -rp "Enter choice: " choice
|
||||
echo ""
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
publish_intent "on" "active_event"
|
||||
;;
|
||||
2)
|
||||
publish_intent "off" "no_active_event"
|
||||
;;
|
||||
3)
|
||||
# issued and expired both in the past
|
||||
STALE_ISSUED=$(date -u -d '5 minutes ago' +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
STALE_EXPIRES=$(date -u -d '2 minutes ago' +"%Y-%m-%dT%H:%M:%S.000Z")
|
||||
publish_intent "on" "active_event" "$STALE_ISSUED" "$STALE_EXPIRES"
|
||||
echo -e "${YELLOW}⚠ This intent is expired - client must reject it and show 'intent expired' in log${NC}"
|
||||
;;
|
||||
4)
|
||||
if [ -z "$GROUP_ID" ]; then
|
||||
echo -e "${RED}No group_id.${NC}"
|
||||
else
|
||||
TOPIC="$(group_topic)"
|
||||
mosquitto_pub -h "$BROKER" -p "$PORT" -t "$TOPIC" -q 1 --retain \
|
||||
-m '{"schema_version":"1.0","desired_state":"on"}'
|
||||
echo -e "${YELLOW}⚠ Malformed intent published - client must reject with 'missing required field' in log${NC}"
|
||||
fi
|
||||
;;
|
||||
5)
|
||||
clear_intent
|
||||
;;
|
||||
6)
|
||||
show_state_files
|
||||
read -rp "Press Enter to continue..."
|
||||
;;
|
||||
7)
|
||||
watch_logs
|
||||
;;
|
||||
8)
|
||||
subscribe_power_state
|
||||
;;
|
||||
q|Q)
|
||||
echo "Exiting."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Invalid choice${NC}"
|
||||
;;
|
||||
esac
|
||||
echo ""
|
||||
done
|
||||
Reference in New Issue
Block a user