feat(app): merge multi-copy work — modules, auth, UI amiga shell, scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-11 00:03:58 +01:00
parent 226e15de7b
commit 8d874ae31b
101 changed files with 9051 additions and 20983 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
EXIT_CODES = {
"normal_boot_ok": 0,
"safe_diagnostic_ok": 10,
"build_blocked": 20,
"runtime_regression": 30,
}
def load_text(path: Path | None) -> str:
if path is None or not path.exists():
return ""
return path.read_text(encoding="utf-8", errors="ignore")
def relevant_segment(text: str) -> str:
for marker in ("[HOST] ", "[MAIN] Freenove all-in-one boot", "[BOOT] safe diagnostic mode enabled"):
idx = text.rfind(marker if marker != "[HOST] " else "SENT RESET")
if idx != -1:
return text[idx:]
return text
def has_any(text: str, needles: list[str]) -> bool:
return any(needle in text for needle in needles)
def classify(text: str, build_ok: bool, upload_ok: bool) -> tuple[str, list[str]]:
evidence: list[str] = []
if not build_ok or not upload_ok:
if not build_ok:
evidence.append("build_ok=0")
if not upload_ok:
evidence.append("upload_ok=0")
return "build_blocked", evidence
segment = relevant_segment(text)
psram_match = re.findall(r"psram_found=(\d)", segment)
if psram_match:
evidence.append(f"psram_found={psram_match[-1]}")
safe_markers = [
"[BOOT] safe diagnostic mode enabled: PSRAM required, app stack disabled",
"[SAFE] boot path: storage + serial + display + buttons only",
]
safe_forbidden = [
"[NET]",
"[WEB]",
"[CAM] boot start",
"wifi:alloc pp wdev funcs fail",
"tag=fx_",
"tag=fx_sprite",
]
fatal_markers = [
"Guru Meditation",
"Backtrace:",
"abort()",
"panic",
"wifi:alloc pp wdev funcs fail",
"[MEM] alloc_fail",
]
if has_any(segment, safe_markers):
evidence.extend(marker for marker in safe_markers if marker in segment)
forbidden = [marker for marker in safe_forbidden if marker in segment]
if not forbidden and "STATUS mode=safe_diagnostic" in segment:
evidence.append("STATUS mode=safe_diagnostic")
return "safe_diagnostic_ok", evidence
evidence.extend(f"forbidden={marker}" for marker in forbidden)
return "runtime_regression", evidence
normal_markers = [
"psram_found=1",
"LVGL + display ready",
"PONG",
]
if has_any(segment, normal_markers):
evidence.extend(marker for marker in normal_markers if marker in segment)
fatals = [marker for marker in fatal_markers if marker in segment]
if "psram_found=1" in segment and "LVGL + display ready" in segment and not fatals:
return "normal_boot_ok", evidence
evidence.extend(f"fatal={marker}" for marker in fatals)
return "runtime_regression", evidence
if segment.strip():
evidence.append("boot segment captured but acceptance markers missing")
else:
evidence.append("boot log empty")
return "runtime_regression", evidence
def write_summary(path: Path, state: str, evidence: list[str], log_path: Path | None) -> None:
lines = [
f"state={state}",
]
if log_path is not None:
lines.append(f"log={log_path}")
for item in evidence:
lines.append(f"evidence={item}")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Classify ESP32 boot logs.")
parser.add_argument("--log")
parser.add_argument("--summary", required=True)
parser.add_argument("--build-ok", type=int, choices=(0, 1), default=1)
parser.add_argument("--upload-ok", type=int, choices=(0, 1), default=1)
args = parser.parse_args()
log_path = Path(args.log) if args.log else None
summary_path = Path(args.summary)
text = load_text(log_path)
state, evidence = classify(text, build_ok=bool(args.build_ok), upload_ok=bool(args.upload_ok))
write_summary(summary_path, state, evidence, log_path)
print(state)
return EXIT_CODES[state]
if __name__ == "__main__":
raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
VENV_DIR="${ZACUS_VENV_DIR:-$ROOT_DIR/.venv}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "[BOOTSTRAP] python3 not found" >&2
exit 1
fi
if [ ! -d "$VENV_DIR" ]; then
echo "[BOOTSTRAP] creating virtualenv at $VENV_DIR"
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
# shellcheck disable=SC1091
source "$VENV_DIR/bin/activate"
python -m pip install --upgrade pip
python -m pip install platformio esptool pyserial
echo "[BOOTSTRAP] tool versions"
python -m platformio --version
python -m esptool version
python - <<'PY'
import serial
print(f"pyserial {serial.VERSION}")
PY
cat <<EOF
[BOOTSTRAP] next steps
source "$VENV_DIR/bin/activate"
python -m platformio run -e freenove_esp32s3_full_with_ui
python -m platformio run -e freenove_esp32s3_full_with_ui -t buildfs
python -m platformio run -e freenove_esp32s3_full_with_ui -t uploadfs --upload-port <PORT>
python -m platformio run -e freenove_esp32s3_full_with_ui -t upload --upload-port <PORT>
python -m platformio device monitor -b 115200 --port <PORT>
EOF
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3}"
STATUS=0
ok() {
printf '[OK] %s\n' "$1"
}
warn() {
printf '[WARN] %s\n' "$1"
}
err() {
printf '[ERR] %s\n' "$1" >&2
STATUS=1
}
cd "$ROOT_DIR"
for cmd in bash rg "$PYTHON_BIN"; do
if command -v "$cmd" >/dev/null 2>&1; then
ok "tool available: $cmd"
else
err "missing tool: $cmd"
fi
done
if command -v pio >/dev/null 2>&1; then
ok "pio available: $(pio --version | head -n 1)"
elif [ -x "$ROOT_DIR/.venv/bin/python" ] && "$ROOT_DIR/.venv/bin/python" -m platformio --version >/dev/null 2>&1; then
ok "platformio available in .venv: $("$ROOT_DIR/.venv/bin/python" -m platformio --version | head -n 1)"
else
warn "PlatformIO not found in PATH or .venv"
fi
platform_pin="$(rg -n '^platform = ' platformio.ini | sed 's/^[0-9]*://')"
ok "platform pin: ${platform_pin:-missing}"
echo "[INFO] supported envs"
rg '^\[env:' platformio.ini | sed 's/^\[env://; s/\]$//'
for path in \
README.md \
README_ESP32_ZACUS.md \
ui_freenove_allinone/README.md \
docs/QUICKSTART.md \
docs/FNK0102H_SOURCE_OF_TRUTH.md \
boards/freenove_esp32_s3_wroom.json \
scripts/bootstrap_platformio.sh \
scripts/doctor_repo.sh \
scripts/flash_monitor_audit.sh \
scripts/serial_boot_capture.py \
scripts/audit_boot_log.py \
tests/sprint1_utility_contract.py \
tests/sprint2_capture_contract.py \
tests/sprint3_audio_contract.py \
tests/phase9_ui_validation.py; do
if [ -e "$path" ]; then
ok "path present: $path"
else
err "path missing: $path"
fi
done
"$PYTHON_BIN" - <<'PY' || STATUS=1
from pathlib import Path
import re
import sys
root = Path.cwd()
files = [
root / "README.md",
root / "README_ESP32_ZACUS.md",
root / "ui_freenove_allinone" / "README.md",
root / "docs" / "QUICKSTART.md",
root / "docs" / "FNK0102H_SOURCE_OF_TRUTH.md",
]
legacy_needles = ("hardware/firmware", "tools/dev/", "protocol/", "../hardware/")
status = 0
for path in files:
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"\[[^\]]+\]\(([^)]+)\)", text):
target = match.group(1).strip()
if not target or target.startswith(("http://", "https://", "#", "mailto:")):
continue
target = target.split("#", 1)[0]
if not (path.parent / target).resolve().exists():
print(f"[ERR] broken markdown link in {path.relative_to(root)} -> {target}")
status = 1
for needle in legacy_needles:
if needle in text:
print(f"[ERR] stale legacy reference in {path.relative_to(root)} -> {needle}")
status = 1
if status == 0:
print("[OK] markdown links and legacy path scan clean")
sys.exit(status)
PY
if [ "$STATUS" -ne 0 ]; then
err "doctor detected blocking issues"
else
ok "doctor completed without blocking issues"
fi
exit "$STATUS"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_NAME="${ZACUS_ENV:-freenove_esp32s3_full_with_ui}"
MONITOR_SECONDS="${ZACUS_MONITOR_SECONDS:-90}"
DEFAULT_PORT="/dev/cu.usbmodem5AB90753301"
if [ -x "$ROOT_DIR/.venv/bin/python" ]; then
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
else
PYTHON_BIN="${PYTHON_BIN:-python3}"
fi
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
BUILD_ARTIFACT_DIR="$ROOT_DIR/artifacts/build"
MONITOR_ARTIFACT_DIR="$ROOT_DIR/artifacts/monitor"
AUDIT_ARTIFACT_DIR="$ROOT_DIR/artifacts/audit"
TEST_ARTIFACT_DIR="$ROOT_DIR/artifacts/tests"
SUMMARY_FILE="$AUDIT_ARTIFACT_DIR/${TIMESTAMP}_summary.txt"
MONITOR_LOG="$MONITOR_ARTIFACT_DIR/${TIMESTAMP}_boot.log"
ESPTOOL_LOG="$AUDIT_ARTIFACT_DIR/${TIMESTAMP}_esptool.log"
mkdir -p "$BUILD_ARTIFACT_DIR" "$MONITOR_ARTIFACT_DIR" "$AUDIT_ARTIFACT_DIR" "$TEST_ARTIFACT_DIR"
log() {
printf '[CHAIN] %s\n' "$1"
}
detect_port() {
if [ -n "${ZACUS_SERIAL_PORT:-}" ] && [ -e "${ZACUS_SERIAL_PORT}" ]; then
printf '%s\n' "${ZACUS_SERIAL_PORT}"
return 0
fi
if [ -e "$DEFAULT_PORT" ]; then
printf '%s\n' "$DEFAULT_PORT"
return 0
fi
mapfile -t ports < <(find /dev -maxdepth 1 -name 'cu.usbmodem*' | sort)
if [ "${#ports[@]}" -eq 1 ]; then
printf '%s\n' "${ports[0]}"
return 0
fi
return 1
}
run_stage() {
local stage="$1"
shift
local logfile="$BUILD_ARTIFACT_DIR/${TIMESTAMP}_${stage}.log"
log "stage=$stage"
if "$@" 2>&1 | tee "$logfile"; then
return 0
fi
return 1
}
write_blocked_summary() {
local build_ok="$1"
local upload_ok="$2"
"$PYTHON_BIN" "$ROOT_DIR/scripts/audit_boot_log.py" \
--summary "$SUMMARY_FILE" \
--build-ok "$build_ok" \
--upload-ok "$upload_ok" >/dev/null
}
run_upload_target() {
local target="$1"
local port="$2"
run_stage "$target" "$PYTHON_BIN" -m platformio run -e "$ENV_NAME" -t "$target" --upload-port "$port"
}
run_upload_with_retry() {
local result_var="$1"
local target="$2"
local port="$3"
if run_upload_target "$target" "$port"; then
printf -v "$result_var" '%s' "$port"
return 0
fi
log "retry stage=$target port=$port"
if [ -e "$port" ] && run_upload_target "$target" "$port"; then
printf -v "$result_var" '%s' "$port"
return 0
fi
local fallback_port
fallback_port="$(detect_port)" || return 1
if [ "$fallback_port" != "$port" ] && run_upload_target "$target" "$fallback_port"; then
printf -v "$result_var" '%s' "$fallback_port"
return 0
fi
return 1
}
run_test() {
local name="$1"
shift
local logfile="$TEST_ARTIFACT_DIR/${TIMESTAMP}_${name}.log"
log "test=$name"
"$@" 2>&1 | tee "$logfile"
}
main() {
cd "$ROOT_DIR"
local port
port="$(detect_port)" || {
printf 'state=hardware_unreachable\nreason=no_serial_port\n' >"$SUMMARY_FILE"
log "no serial port detected"
return 40
}
export ZACUS_SERIAL_PORT="$port"
if ! run_stage build "$PYTHON_BIN" -m platformio run -e "$ENV_NAME"; then
write_blocked_summary 0 0
return 20
fi
if ! run_stage buildfs "$PYTHON_BIN" -m platformio run -e "$ENV_NAME" -t buildfs; then
write_blocked_summary 0 0
return 20
fi
if ! run_upload_with_retry port uploadfs "$port"; then
write_blocked_summary 1 0
return 20
fi
export ZACUS_SERIAL_PORT="$port"
if ! run_upload_with_retry port upload "$port"; then
write_blocked_summary 1 0
return 20
fi
export ZACUS_SERIAL_PORT="$port"
if ! "$PYTHON_BIN" "$ROOT_DIR/scripts/serial_boot_capture.py" \
--port "$port" \
--baud 115200 \
--seconds "$MONITOR_SECONDS" \
--log "$MONITOR_LOG"; then
printf 'state=hardware_unreachable\nreason=serial_capture_failed\nport=%s\nlog=%s\n' "$port" "$MONITOR_LOG" >"$SUMMARY_FILE"
return 40
fi
local audit_state
set +e
audit_state="$("$PYTHON_BIN" "$ROOT_DIR/scripts/audit_boot_log.py" \
--log "$MONITOR_LOG" \
--summary "$SUMMARY_FILE" \
--build-ok 1 \
--upload-ok 1)"
local audit_rc=$?
set -e
if [ "$audit_state" = "safe_diagnostic_ok" ]; then
{
printf '[ESPTOOL] flash_id\n'
"$PYTHON_BIN" -m esptool --chip esp32s3 --port "$port" flash_id
printf '\n[ESPTOOL] read_mac\n'
"$PYTHON_BIN" -m esptool --chip esp32s3 --port "$port" read_mac
} 2>&1 | tee "$ESPTOOL_LOG"
{
printf 'finding=containment_logic_ok_root_cause_probable_hardware_or_memory_config\n'
printf 'expected_board=FNK0102H/ESP32-S3-WROOM-1-N16R8\n'
printf 'esptool_log=%s\n' "$ESPTOOL_LOG"
} >>"$SUMMARY_FILE"
return "$audit_rc"
fi
if [ "$audit_state" = "normal_boot_ok" ]; then
run_test sprint1 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint1_utility_contract.py" --mode serial --cycles 1 --port "$port"
run_test sprint2 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint2_capture_contract.py" --mode serial --cycles 1 --port "$port"
run_test sprint3 "$PYTHON_BIN" "$ROOT_DIR/tests/sprint3_audio_contract.py" --mode serial --cycles 1 --port "$port"
run_test phase9 "$PYTHON_BIN" "$ROOT_DIR/tests/phase9_ui_validation.py" --port "$port"
printf 'tests=serial_smoke_pass\n' >>"$SUMMARY_FILE"
return 0
fi
return "$audit_rc"
}
main "$@"
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import serial
BOOT_BANNER = "[MAIN] Freenove all-in-one boot"
def host_line(text: str) -> str:
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
return f"[HOST] {stamp} {text}"
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
ser = serial.Serial()
ser.port = port
ser.baudrate = baud
ser.timeout = timeout
ser.write_timeout = 1.0
ser.dtr = False
ser.rts = False
ser.open()
time.sleep(0.25)
return ser
def flush_lines(ser: serial.Serial, log_handle, deadline: float) -> list[str]:
lines: list[str] = []
pending = ""
while time.time() < deadline:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
pending += chunk.decode("utf-8", errors="ignore")
while "\n" in pending:
raw_line, pending = pending.split("\n", 1)
line = raw_line.rstrip("\r")
log_handle.write(line + "\n")
log_handle.flush()
lines.append(line)
if pending:
line = pending.rstrip("\r")
log_handle.write(line + "\n")
log_handle.flush()
lines.append(line)
return lines
def send_command(ser: serial.Serial, log_handle, command: str, wait_s: float) -> list[str]:
log_handle.write(host_line(f"SENT {command}") + "\n")
log_handle.flush()
ser.write((command + "\n").encode("utf-8"))
ser.flush()
return flush_lines(ser, log_handle, time.time() + wait_s)
def wait_for_command_channel(ser: serial.Serial, log_handle, timeout_s: float) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
flush_lines(ser, log_handle, time.time() + 0.35)
response = send_command(ser, log_handle, "PING", wait_s=1.2)
if any("PONG" in line or "UNKNOWN PING" in line for line in response):
log_handle.write(host_line("COMMAND CHANNEL READY") + "\n")
log_handle.flush()
return True
time.sleep(0.4)
log_handle.write(host_line("COMMAND CHANNEL NOT READY") + "\n")
log_handle.flush()
return False
def wait_for_quiet_period(
ser: serial.Serial,
log_handle,
timeout_s: float,
quiet_s: float,
) -> tuple[bool, float]:
deadline = time.time() + timeout_s
last_rx_at = time.time()
while time.time() < deadline:
lines = flush_lines(ser, log_handle, time.time() + 0.25)
if lines:
last_rx_at = time.time()
elif (time.time() - last_rx_at) >= quiet_s:
return True, last_rx_at
return False, last_rx_at
def main() -> int:
parser = argparse.ArgumentParser(description="Capture a rebooted ESP32 boot log over serial.")
parser.add_argument("--port", required=True)
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=0.2)
parser.add_argument("--seconds", type=float, default=90.0)
parser.add_argument("--log", required=True)
args = parser.parse_args()
log_path = Path(args.log)
log_path.parent.mkdir(parents=True, exist_ok=True)
try:
with log_path.open("w", encoding="utf-8") as log_handle:
log_handle.write(host_line(f"OPEN port={args.port} baud={args.baud}") + "\n")
log_handle.flush()
ser = open_serial(args.port, args.baud, args.timeout)
try:
ready = wait_for_command_channel(ser, log_handle, timeout_s=20.0)
if ready:
ser.close()
time.sleep(1.2)
ser = open_serial(args.port, args.baud, args.timeout)
log_handle.write(host_line("REOPEN FOR BOOT CAPTURE") + "\n")
log_handle.flush()
capture_deadline = time.time() + args.seconds
boot_seen = False
post_boot_commands_sent = False
boot_seen_at = 0.0
last_rx_at = time.time()
phase_start = time.time()
while time.time() < capture_deadline:
lines = flush_lines(ser, log_handle, time.time() + 0.25)
if lines:
last_rx_at = time.time()
for line in lines:
if BOOT_BANNER in line:
boot_seen = True
boot_seen_at = time.time()
should_probe = False
if (
boot_seen
and not post_boot_commands_sent
and (
(time.time() - last_rx_at) >= 1.8
or (time.time() - boot_seen_at) >= 18.0
)
):
should_probe = True
if (
not boot_seen
and not post_boot_commands_sent
and (time.time() - phase_start) >= 18.0
):
should_probe = True
if should_probe:
send_command(ser, log_handle, "PING", wait_s=2.0)
send_command(ser, log_handle, "STATUS", wait_s=2.4)
post_boot_commands_sent = True
log_handle.write(host_line("CAPTURE COMPLETE") + "\n")
log_handle.flush()
finally:
try:
ser.close()
except Exception:
pass
except serial.SerialException as exc:
print(f"serial capture failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())