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:
@@ -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
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user