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.
This commit is contained in:
nearxos
2026-02-21 16:02:08 +02:00
parent 5238d457e8
commit b39e73324f
4 changed files with 215 additions and 8 deletions

View File

@@ -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. Admin: login required — backups, cloud-init, portal files, set golden, users.
""" """
import hashlib
import json import json
import os import os
import re import re
import shutil import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import tempfile
import time import time
import urllib.request import urllib.request
from functools import wraps 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"))) 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") 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") 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) --- # --- Database (admin users + activity logs) ---
@@ -229,6 +237,72 @@ def _write_status(phase, message, progress=None):
pass 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(): def read_status():
try: try:
with open(STATUS_FILE, "r") as f: with open(STATUS_FILE, "r") as f:
@@ -518,6 +592,17 @@ def api_log():
return jsonify({"log": read_log_tail()}) 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") @app.route("/api/pending-devices")
def api_pending_devices(): def api_pending_devices():
"""Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy.""" """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"]) @app.route("/api/device-action", methods=["POST"])
def api_device_action(): 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 {} body = request.get_json(force=True, silent=True) or {}
source = (body.get("source") or "").strip().lower() source = (body.get("source") or "").strip().lower()
action = (body.get("action") or "").strip().lower() action = (body.get("action") or "").strip().lower()
if action not in ("backup", "deploy", "reboot"): if action not in ("backup", "deploy", "reboot", "eeprom_update"):
return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', or 'reboot'"}), 400 return jsonify({"ok": False, "error": "action must be 'backup', 'deploy', 'reboot', or 'eeprom_update'"}), 400
if action == "reboot" and source != "network": if action == "reboot" and source != "network":
return jsonify({"ok": False, "error": "'reboot' is only for network devices"}), 400 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 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: try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) 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 # If user requested "shrink after backup", create flag so host runs PiShrink after dd

View File

@@ -165,7 +165,8 @@
margin-bottom: 0.2rem; margin-bottom: 0.2rem;
} }
.device-desc { font-size: 0.9rem; color: var(--text); } .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 { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.85rem; font-size: 0.85rem;
@@ -557,7 +558,7 @@
if (shrinkWrap) shrinkWrap.style.display = 'block'; if (shrinkWrap) shrinkWrap.style.display = 'block';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'device-item'; el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>'; el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup, Deploy, or Update EEPROM</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button><select class="eeprom-preset" title="Boot order"><option value="0xf21">eMMC first, then network</option><option value="0x1">eMMC only</option><option value="0xf12">Network first, then eMMC</option></select><button type="button" class="btn btn-outline" data-source="usb" data-action="eeprom_update">Update EEPROM</button></div>';
container.appendChild(el); container.appendChild(el);
} else { } else {
if (shrinkWrap) shrinkWrap.style.display = 'none'; if (shrinkWrap) shrinkWrap.style.display = 'none';
@@ -581,6 +582,10 @@
const mac = btn.getAttribute('data-mac'); const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action }; const body = { source: source, action: action };
if (mac) body.mac = mac; 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'); const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true; if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })

View File

@@ -152,10 +152,10 @@ if [[ -z "$target_dev" ]]; then
fi fi
# Ask user (dashboard): Backup or Deploy? # 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 "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true
echo "$target_dev" > "$CURRENT_DEVICE_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 for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
sleep 2 sleep 2
if [[ -f "$ACTION_REQUEST_FILE" ]]; then if [[ -f "$ACTION_REQUEST_FILE" ]]; then
@@ -225,8 +225,48 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
else else
write_status "error" "Flash failed" "null" "dd failed" write_status "error" "Flash failed" "null" "dd failed"
fi 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 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 fi
exit 0 exit 0
fi fi

View File

@@ -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@<LXC-IP> '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"