From b39e73324f535b79cfc68d9ef8fb6c28b532e627 Mon Sep 17 00:00:00 2001 From: nearxos Date: Sat, 21 Feb 2026 16:02:08 +0200 Subject: [PATCH] Add EEPROM update functionality and UI enhancements Implement a new feature to allow users to update the EEPROM via the dashboard, including the generation of necessary update files. Enhance the device action handling to support the 'eeprom_update' action for USB-connected devices, ensuring proper validation of boot order presets. Update the dashboard UI to include an EEPROM update option alongside existing actions, improving user experience. Modify related scripts to handle EEPROM updates effectively, including file management and error handling during the update process. --- emmc-provisioning/dashboard/app.py | 108 +++++++++++++++++- .../dashboard/templates/index.html | 9 +- .../host/flash-emmc-on-connect.sh | 46 +++++++- .../scripts/install-eeprom-tools-on-lxc.sh | 60 ++++++++++ 4 files changed, 215 insertions(+), 8 deletions(-) create mode 100755 emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh diff --git a/emmc-provisioning/dashboard/app.py b/emmc-provisioning/dashboard/app.py index 69de5bb..6ff9f53 100644 --- a/emmc-provisioning/dashboard/app.py +++ b/emmc-provisioning/dashboard/app.py @@ -5,12 +5,14 @@ Public home: deploy only (status, logs, how to connect). No login. Admin: login required — backups, cloud-init, portal files, set golden, users. """ +import hashlib import json import os import re import shutil import sqlite3 import subprocess +import tempfile import time import urllib.request from functools import wraps @@ -49,6 +51,12 @@ PORTAL_DESCRIPTIONS_FILE = Path(os.environ.get("CM4_PORTAL_DESCRIPTIONS_FILE", s DB_PATH = Path(os.environ.get("CM4_DASHBOARD_DB", str(BASE_DIR / "dashboard.db"))) TOGGLE_NETWORK_BOOT_SCRIPT = os.environ.get("CM4_TOGGLE_NETWORK_BOOT_SCRIPT", "/opt/cm4-provisioning/toggle-network-boot-dhcp.sh") DHCP_LEASES_FILE = os.environ.get("CM4_DHCP_LEASES_FILE", "/var/lib/misc/dnsmasq.leases") +# EEPROM update (USB boot): tools and output files (shared with host script) +EEPROM_DIR = Path(os.environ.get("CM4_EEPROM_DIR", "/opt/cm4-provisioning/eeprom")) +EEPROM_CONFIG_TOOL = EEPROM_DIR / "rpi-eeprom-config" +EEPROM_FW_FILE = EEPROM_DIR / "pieeprom.bin" +EEPROM_UPD_FILE = BASE_DIR / "pieeprom.upd" +EEPROM_SIG_FILE = BASE_DIR / "pieeprom.sig" # --- Database (admin users + activity logs) --- @@ -229,6 +237,72 @@ def _write_status(phase, message, progress=None): pass +def _generate_eeprom_update(boot_order, extra_settings=None): + """Generate pieeprom.upd and pieeprom.sig in BASE_DIR. Returns (True, None) or (False, error_message).""" + extra_settings = extra_settings or {} + if not EEPROM_CONFIG_TOOL.is_file() or not EEPROM_FW_FILE.is_file(): + return False, "EEPROM tools not installed. Run install-eeprom-tools-on-lxc.sh on the LXC." + try: + os.makedirs(BASE_DIR, exist_ok=True) + # Read current config from firmware image + out = subprocess.run( + [str(EEPROM_CONFIG_TOOL), str(EEPROM_FW_FILE)], + capture_output=True, + text=True, + timeout=30, + cwd=str(EEPROM_DIR), + ) + if out.returncode != 0: + return False, (out.stderr or out.stdout or "rpi-eeprom-config failed").strip()[:500] + lines = (out.stdout or "").strip().splitlines() + # Strip lines we replace + skip_keys = {"BOOT_ORDER", "NET_BOOT_MAX_RETRIES", "DHCP_TIMEOUT", "DHCP_REQ_TIMEOUT", "TFTP_IP", "NET_INSTALL_AT_POWER_ON"} + new_lines = [] + for line in lines: + key = line.split("=", 1)[0].strip() if "=" in line else "" + if key in skip_keys: + continue + new_lines.append(line) + # Add our settings + new_lines.append(f"BOOT_ORDER={boot_order}") + new_lines.append("NET_BOOT_MAX_RETRIES=3") + new_lines.append("DHCP_TIMEOUT=1500") + new_lines.append("DHCP_REQ_TIMEOUT=500") + new_lines.append("NET_INSTALL_AT_POWER_ON=0") + for k, v in extra_settings.items(): + new_lines.append(f"{k}={v}") + with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as f: + f.write("\n".join(new_lines) + "\n") + conf_path = f.name + try: + out2 = subprocess.run( + [str(EEPROM_CONFIG_TOOL), "--config", conf_path, "--out", str(EEPROM_UPD_FILE), str(EEPROM_FW_FILE)], + capture_output=True, + text=True, + timeout=60, + cwd=str(EEPROM_DIR), + ) + if out2.returncode != 0: + return False, (out2.stderr or out2.stdout or "rpi-eeprom-config --config failed").strip()[:500] + if not EEPROM_UPD_FILE.is_file() or EEPROM_UPD_FILE.stat().st_size == 0: + return False, "Generated pieeprom.upd is missing or empty" + # Write signature (sha256 hex) + h = hashlib.sha256() + with open(EEPROM_UPD_FILE, "rb") as f: + h.update(f.read()) + EEPROM_SIG_FILE.write_text(h.hexdigest()) + return True, None + finally: + try: + os.unlink(conf_path) + except OSError: + pass + except subprocess.TimeoutExpired: + return False, "Timeout running rpi-eeprom-config" + except (PermissionError, OSError) as e: + return False, str(e) + + def read_status(): try: with open(STATUS_FILE, "r") as f: @@ -518,6 +592,17 @@ def api_log(): return jsonify({"log": read_log_tail()}) +@app.route("/api/eeprom-presets") +def api_eeprom_presets(): + """Return available boot order presets for the Update EEPROM dropdown.""" + presets = [ + {"id": "0x1", "label": "eMMC only"}, + {"id": "0xf21", "label": "eMMC first, then network"}, + {"id": "0xf12", "label": "Network first, then eMMC"}, + ] + return jsonify({"presets": presets}) + + @app.route("/api/pending-devices") def api_pending_devices(): """Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy.""" @@ -532,15 +617,32 @@ def api_pending_devices(): @app.route("/api/device-action", methods=["POST"]) def api_device_action(): - """User chose Backup or Deploy for a device. source=usb | network; for network pass mac=.""" + """User chose Backup, Deploy, or Update EEPROM for a device. source=usb | network; for network pass mac=.""" body = request.get_json(force=True, silent=True) or {} source = (body.get("source") or "").strip().lower() action = (body.get("action") or "").strip().lower() - if action not in ("backup", "deploy", "reboot"): - return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'reboot'"}), 400 + if action not in ("backup", "deploy", "reboot", "eeprom_update"): + return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', 'reboot', or 'eeprom_update'"}), 400 if action == "reboot" and source != "network": return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 + if action == "eeprom_update" and source != "usb": + return jsonify({"ok": False, "error": "'eeprom_update' is only for USB-connected devices"}), 400 if source == "usb": + if action == "eeprom_update": + boot_order = (body.get("boot_order") or "0xf21").strip().lower() + if boot_order not in ("0x1", "0xf21", "0xf12"): + return jsonify({"ok": False, "error": "boot_order must be 0x1, 0xf21, or 0xf12"}), 400 + ok, err = _generate_eeprom_update(boot_order) + if not ok: + return jsonify({"ok": False, "error": err or "Failed to generate EEPROM update"}), 500 + try: + os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) + with open(ACTION_REQUEST_FILE, "w") as f: + f.write("eeprom_update") + _write_status("eeprom_update", "EEPROM update ready; host will write to device boot partition.") + return jsonify({"ok": True}) + except (PermissionError, OSError): + return jsonify({"ok": False, "error": "Could not write action file"}), 500 try: os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) # If user requested "shrink after backup", create flag so host runs PiShrink after dd diff --git a/emmc-provisioning/dashboard/templates/index.html b/emmc-provisioning/dashboard/templates/index.html index 6cc310d..d5c5cb1 100644 --- a/emmc-provisioning/dashboard/templates/index.html +++ b/emmc-provisioning/dashboard/templates/index.html @@ -165,7 +165,8 @@ margin-bottom: 0.2rem; } .device-desc { font-size: 0.9rem; color: var(--text); } - .device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; } + .device-actions { display: flex; gap: 0.5rem; flex-shrink: 0; align-items: center; } + .eeprom-preset { padding: 0.35rem 0.5rem; font-size: 0.85rem; font-family: inherit; background: var(--bg-tertiary); color: var(--text); border: 1px solid var(--border); border-radius: 6px; max-width: 12rem; } .btn { padding: 0.5rem 1rem; font-size: 0.85rem; @@ -557,7 +558,7 @@ if (shrinkWrap) shrinkWrap.style.display = 'block'; const el = document.createElement('div'); el.className = 'device-item'; - el.innerHTML = '
USB boot
Device connected — choose Backup or Deploy
'; + el.innerHTML = '
USB boot
Device connected — choose Backup, Deploy, or Update EEPROM
'; container.appendChild(el); } else { if (shrinkWrap) shrinkWrap.style.display = 'none'; @@ -581,6 +582,10 @@ const mac = btn.getAttribute('data-mac'); const body = { source: source, action: action }; if (mac) body.mac = mac; + if (action === 'eeprom_update' && source === 'usb') { + const presetEl = btn.closest('.device-item') && btn.closest('.device-item').querySelector('.eeprom-preset'); + body.boot_order = (presetEl && presetEl.value) ? presetEl.value : '0xf21'; + } const shrinkCb = document.getElementById('shrinkAfterBackup'); if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true; fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) diff --git a/emmc-provisioning/host/flash-emmc-on-connect.sh b/emmc-provisioning/host/flash-emmc-on-connect.sh index 20ae994..e608b3b 100644 --- a/emmc-provisioning/host/flash-emmc-on-connect.sh +++ b/emmc-provisioning/host/flash-emmc-on-connect.sh @@ -152,10 +152,10 @@ if [[ -z "$target_dev" ]]; then fi # Ask user (dashboard): Backup or Deploy? -write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null" +write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup, Deploy, or Update EEPROM in the dashboard." "null" echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true -log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..." +log "Waiting for user choice (Backup, Deploy, or Update EEPROM) in dashboard; timeout ${WAIT_TIMEOUT}s..." for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do sleep 2 if [[ -f "$ACTION_REQUEST_FILE" ]]; then @@ -225,8 +225,48 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do else write_status "error" "Flash failed" "null" "dd failed" fi + elif [[ "$action" == "eeprom_update" ]]; then + # Dashboard has written pieeprom.upd and pieeprom.sig to BASE_DIR; copy to eMMC boot partition + PROV_DIR="$(dirname "$STATUS_FILE")" + EEPROM_UPD="$PROV_DIR/pieeprom.upd" + EEPROM_SIG="$PROV_DIR/pieeprom.sig" + if [[ ! -f "$EEPROM_UPD" || ! -f "$EEPROM_SIG" ]]; then + log "EEPROM update files not found: $EEPROM_UPD / $EEPROM_SIG" + write_status "error" "EEPROM update failed" "null" "pieeprom.upd or pieeprom.sig not found in $PROV_DIR" + exit 1 + fi + boot_part="" + for p in "${target_dev}1" "${target_dev}p1"; do + if [[ -b "$p" ]]; then + boot_part="$p" + break + fi + done + if [[ -z "$boot_part" ]]; then + log "No boot partition found for $target_dev (tried ...1 and ...p1)" + write_status "error" "EEPROM update failed" "null" "Boot partition not found" + exit 1 + fi + write_status "eeprom_update" "Writing EEPROM update to boot partition…" "null" + log "Mounting $boot_part and copying EEPROM update..." + mnt=$(mktemp -d) + if mount "$boot_part" "$mnt" 2>/dev/null; then + if cp "$EEPROM_UPD" "$mnt/pieeprom.upd" && cp "$EEPROM_SIG" "$mnt/pieeprom.sig"; then + sync + log "EEPROM update written. Remove eMMC disable jumper and power cycle to apply." + write_status "done" "EEPROM update written to boot partition. Remove eMMC disable jumper and power cycle the reTerminal to apply." "100" + rm -f "$EEPROM_UPD" "$EEPROM_SIG" + ( sleep 90 && write_status "idle" "Waiting for reTerminal in boot mode or network." "null" ) & + else + write_status "error" "EEPROM update failed" "null" "Failed to copy pieeprom.upd/sig" + fi + umount "$mnt" 2>/dev/null || true + else + write_status "error" "EEPROM update failed" "null" "Could not mount boot partition" + fi + rm -rf "$mnt" else - write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'" + write_status "error" "Unknown action" "null" "action_request must be 'backup', 'deploy', or 'eeprom_update'" fi exit 0 fi diff --git a/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh b/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh new file mode 100755 index 0000000..eb339d4 --- /dev/null +++ b/emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Install rpi-eeprom-config and pieeprom.bin on the LXC (or host) where the dashboard runs. +# Required for "Update EEPROM" from the dashboard when a device is connected via USB boot. +# Run on the LXC: bash -s < scripts/install-eeprom-tools-on-lxc.sh +# Or: ssh root@ 'bash -s' < emmc-provisioning/scripts/install-eeprom-tools-on-lxc.sh + +set -e +RPI_EEPROM_RAW="https://raw.githubusercontent.com/raspberrypi/rpi-eeprom/master" +EEPROM_DIR="${EEPROM_DIR:-/opt/cm4-provisioning/eeprom}" + +echo "Installing EEPROM tools to $EEPROM_DIR" + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: Python 3 is required to run rpi-eeprom-config. Install it (e.g. apt install python3) and re-run." + exit 1 +fi + +if command -v curl >/dev/null 2>&1; then + GET="curl -sL" + GET_O="curl -sL -o" +elif command -v wget >/dev/null 2>&1; then + GET="wget -q -O -" + GET_O="wget -q -O" +else + echo "ERROR: curl or wget required" + exit 1 +fi + +mkdir -p "$EEPROM_DIR" +cd "$EEPROM_DIR" + +echo "Downloading rpi-eeprom-config..." +$GET_O "$EEPROM_DIR/rpi-eeprom-config" "$RPI_EEPROM_RAW/rpi-eeprom-config" +$GET_O "$EEPROM_DIR/rpi-eeprom-digest" "$RPI_EEPROM_RAW/rpi-eeprom-digest" +chmod +x "$EEPROM_DIR/rpi-eeprom-config" "$EEPROM_DIR/rpi-eeprom-digest" + +echo "Finding latest BCM2711 EEPROM firmware..." +LATEST_FW=$($GET "https://api.github.com/repos/raspberrypi/rpi-eeprom/contents/firmware-2711/default" \ + | grep -o '"name" *: *"pieeprom-[^"]*\.bin"' | sed 's/"name" *: *"//;s/"//' | sort | tail -1) + +if [ -z "$LATEST_FW" ]; then + echo "WARNING: Could not determine latest firmware from GitHub API. Try again or download pieeprom.bin manually." + exit 1 +fi + +echo "Downloading $LATEST_FW..." +$GET_O "$EEPROM_DIR/pieeprom.bin" "$RPI_EEPROM_RAW/firmware-2711/default/$LATEST_FW" + +if [ ! -s "$EEPROM_DIR/pieeprom.bin" ]; then + echo "ERROR: pieeprom.bin is missing or empty" + exit 1 +fi + +echo "Verifying rpi-eeprom-config..." +if ! python3 "$EEPROM_DIR/rpi-eeprom-config" "$EEPROM_DIR/pieeprom.bin" >/dev/null 2>&1; then + echo "WARNING: rpi-eeprom-config could not read pieeprom.bin (non-fatal)" +fi + +echo "Done. EEPROM tools installed:" +ls -la "$EEPROM_DIR"