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.
"""
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

View File

@@ -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 = '<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);
} 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) })