diskOS installer: initial public beta

Flashes the diskOS custom UI onto the FiiO Snowsky Disc over Ingenic mask-ROM USB,
building the image from your own stock firmware. Runs from source via install.sh.
This commit is contained in:
b0hemia
2026-08-26 15:26:14 +10:00
commit e0bc4785e9
109 changed files with 12625 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
"""Mask-ROM USB flashing - Python wrapper around the PROVEN native binaries
(usbboot + the my_write5 DRAM NAND writer + the X2000 SPL). We do NOT reimplement
the flashing protocol; we orchestrate it, watch it, and interpret the result.
Faithful port of flash_diskos.sh, with the python helper snippets (poison blob,
debug-struct parse) folded in natively, plus honest progress and fail-closed
result interpretation.
"""
import os
import shutil
import signal
import struct
import subprocess
import tempfile
from . import bundle, platform_probe
from .reporter import CLIReporter
from .errors import FlashError, DEVICE_RESULT_CODES, RECOVERABLE
IMG_SIZE = 76021760
SQUASH_MAGIC = b"hsqs"
RESULT_NAMES = {code: name for code, (_fcode, name) in DEVICE_RESULT_CODES.items()}
FLASH_EXPECT_SECS = 75 * 60 # ~60-90 min; expected writer duration
# Hard ceiling for the whole usbboot invocation: the writer's own --wait is 5400s (90 min);
# give it that plus margin for the image download + result readback, then treat a still-running
# usbboot as a hung/reset device and terminate it (E303) rather than blocking forever.
FLASH_HARD_TIMEOUT_SECS = 5400 + 20 * 60 # 90 min writer + 20 min margin
def _probe_helpers():
"""Confirm every bundled native tool can actually execute (and load its libs)
BEFORE the destructive gate. Catches a noexec temp mount / missing dylibs while
the device is still untouched. Non-zero exit is fine; an OSError is not."""
probes = [(bundle.native("usbboot"), ["--help"]),
(bundle.native("mksquashfs"), ["-version"]),
(bundle.native("unsquashfs"), ["-version"])]
for path, args in probes:
try:
subprocess.run([path] + args, env=bundle.native_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=15)
except OSError as e:
raise FlashError(
f"bundled helper '{os.path.basename(path)}' cannot execute ({e})",
code="E103",
action="the tool's directory may be on a noexec mount, or a required library is "
"missing; move the installer to an exec-capable filesystem and retry")
except subprocess.TimeoutExpired:
pass # it started (that's all we needed to prove)
def preflight(image_path, rep=None):
"""Validate the image and that exactly one device is in mask-ROM. Fail closed."""
rep = rep or CLIReporter()
if not os.path.exists(image_path):
raise FlashError(f"image not found: {image_path}", code="E120")
sz = os.path.getsize(image_path)
if sz != IMG_SIZE:
raise FlashError(f"wrong image size {sz} (must be {IMG_SIZE})", code="E121",
action="use a diskOS image built by this installer")
with open(image_path, "rb") as f:
if f.read(4) != SQUASH_MAGIC:
raise FlashError("image is not a squashfs (bad magic) - not a diskOS/stock image", code="E122")
_probe_helpers() # every bundled tool must actually execute (catches noexec /tmp, missing libs)
n = platform_probe.maskrom_count()
if n == 0:
raise FlashError(
"no device in mask-ROM mode", code="E110",
action="power the device OFF, hold Volume-Down, plug in USB (screen stays "
"black), then retry")
if n > 1:
raise FlashError(f"{n} devices in mask-ROM mode - need exactly 1", code="E111",
action="unplug the other Ingenic devices")
if n < 0:
# FAIL CLOSED: we could not prove exactly one device (no libusb backend or a
# permission error). Never cross the destructive gate on an unproven count.
raise FlashError(
"cannot confirm exactly one device (USB enumeration failed - missing libusb "
"backend or insufficient USB permissions)", code="E112",
action="refusing to flash on an unproven device count; fix USB access and retry")
def _parse_debug(dbg_path):
"""Parse the 1KB little-endian debug readback (256 x uint32). Returns a dict.
Mirrors the field offsets in flash_diskos.sh."""
with open(dbg_path, "rb") as f:
raw = f.read(1024)
if len(raw) < 1024:
raise FlashError(f"short debug readback ({len(raw)} bytes) - flash result UNKNOWN", code="E302",
action=RECOVERABLE)
w = struct.unpack("<256I", raw)
nbad = w[20]
return {
"magic": w[0],
"done": w[9],
"skipped": w[10],
"result": w[16],
"retried": w[17],
"worst_retries": w[18],
"bad_found": nbad,
"bad_list": [w[40 + i] for i in range(min(nbad, 64))],
}
def flash(image_path, log_path=None, rep=None):
"""Flash `image_path` to the device via mask-ROM. Returns the parsed debug dict
on SUCCESS; raises FlashError otherwise (fail-closed)."""
rep = rep or CLIReporter()
preflight(image_path, rep)
image_path = os.path.abspath(image_path)
usbboot = bundle.native("usbboot")
writer = bundle.native("my_write5_dram.bin")
spl = bundle.native("disc_spl_lpddr3.bin")
tmp = tempfile.mkdtemp(prefix="diskos-flash-")
poison = os.path.join(tmp, "poison.bin")
dbg = os.path.join(tmp, "dbg.bin")
with open(poison, "wb") as f:
f.write(b"\xee" * 128)
cmd = [
usbboot, "-v", "--cpu", "x2000", "--stage1", spl, "--wait", "2",
"--addr", "0xa0c00000", "--download", writer,
"--addr", "0xa1000000", "--download", image_path,
"--addr", "0xa0a00000", "--download", poison,
"--start1", "0xa0c00030", "--wait", "5400",
"--addr", "0xa0a00000", "--length", "0x400", "--upload", dbg,
]
rep.phase("Flashing diskOS (mask-ROM) - ~60-90 minutes", destructive=True)
rep.warning("Do NOT disconnect the device or let the host sleep during the flash.")
logf = open(log_path, "w") if log_path else open(os.path.join(tmp, "flash.log"), "w")
rep.indeterminate(True, note="flashing (scan → erase → program → verify → retry)",
expect_secs=FLASH_EXPECT_SECS)
try:
try:
with _sleep_inhibited(rep):
# own session so a GUI/parent crash can't SIGPIPE the flasher; output goes
# to a file (never a pipe) so a closed reader can't deadlock or kill it.
proc = subprocess.Popen(cmd, stdout=logf, stderr=subprocess.STDOUT,
env=bundle.native_env(), start_new_session=True)
def _kill_flasher():
# The flasher runs in its OWN session (start_new_session) so a parent SIGPIPE
# can't kill it mid-write - but that also means it SURVIVES us. Kill the whole
# process group so NAND writes actually stop, then reap without blocking.
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except OSError:
proc.kill()
try:
proc.wait(timeout=30) # reap; don't block if stuck in D-state
except subprocess.TimeoutExpired:
pass
try:
# A3: bound the wait. If the device resets/re-enumerates mid-flash,
# usbboot can block on a dead handle forever - never hang the app.
rc = proc.wait(timeout=FLASH_HARD_TIMEOUT_SECS)
except subprocess.TimeoutExpired:
_kill_flasher()
raise FlashError(
f"flash timed out - the device stopped responding after "
f"{FLASH_HARD_TIMEOUT_SECS // 60} min (it most likely reset "
"mid-flash)", code="E303", action=RECOVERABLE)
except BaseException:
# Ctrl-C, SIGTERM, a GUI/parent crash, or any error during the wait: WITHOUT
# this the flasher keeps writing NAND after we return, and a tester who believes
# flashing stopped may unplug mid-write and brick the device. Kill it, then
# propagate the original interruption/error.
_kill_flasher()
raise
finally:
rep.indeterminate(False)
logf.close()
rep.log(f"usbboot exit={rc}")
if not os.path.exists(dbg):
raise FlashError(
"no debug readback produced - flash result UNKNOWN; assume FAILED",
code="E301", action=RECOVERABLE)
d = _parse_debug(dbg)
ok = (d["magic"] == 0x4004E005 and d["done"] == 0x55555555
and d["result"] == 0x600DF10C)
rep.log(f"scan: bad-blocks-found={d['bad_found']} list={d['bad_list']}")
# B4: dbg[17]/[18] mean retried/worst only on SUCCESS; on the out-of-space /
# block-write-fail aborts they hold the last phys/logical block; on the other
# aborts they are 0 and must NOT be shown as "last block" (would be misleading).
if ok:
rep.log(f"write: skipped={d['skipped']} retried={d['retried']} "
f"worst={d['worst_retries']}")
elif d["result"] in (0xDEAD0002, 0xDEAD0003):
rep.log(f"write: skipped={d['skipped']} last-phys-block={d['retried']} "
f"last-logical-block={d['worst_retries']}")
else:
rep.log(f"write: skipped={d['skipped']}")
fcode, name = DEVICE_RESULT_CODES.get(d["result"], ("F000", "UNKNOWN"))
rep.log(f"result: 0x{d['result']:08X} [{fcode}] {name}")
if not ok:
raise FlashError(
f"flash FAILED (device result [{fcode}] {name}, "
f"magic=0x{d['magic']:08X} done=0x{d['done']:08X})",
code="E310", action=RECOVERABLE)
rep.ok("flash verified OK")
return d
finally:
# B3: never leak the per-flash tempdir (poison/dbg/flash.log), but keep the log
# debuggable: if no external log_path was given, preserve the internal log to a
# single stable file (overwritten each flash - bounded, not a growing leak).
internal_log = os.path.join(tmp, "flash.log")
if not log_path and os.path.exists(internal_log):
try:
# Unique, 0600, no symlink-follow: write THROUGH the mkstemp fd (never reopen the path,
# which could follow a swapped-in symlink and truncate an arbitrary target). Path reported.
fd, kept = tempfile.mkstemp(prefix="diskos-flash-", suffix=".log")
with os.fdopen(fd, "wb") as out, open(internal_log, "rb") as src:
shutil.copyfileobj(src, out)
rep.log(f"flash log saved to {kept}")
except OSError:
pass
shutil.rmtree(tmp, ignore_errors=True)
class _sleep_inhibited:
"""Best-effort host sleep inhibitor for the duration of the flash. No-op if the
platform tool isn't available; never blocks or fails the flash. If it CAN'T
inhibit sleep, it warns (B5) - a suspend mid-flash would abort it."""
def __init__(self, rep=None):
self.rep = rep
def __enter__(self):
self.proc = None
o, _ = platform_probe.host()
try:
if o == "macos":
self.proc = subprocess.Popen(["caffeinate", "-dimsu"])
elif o == "linux":
import shutil
if shutil.which("systemd-inhibit"):
# keep a long-lived inhibitor process alive; we kill it on exit
self.proc = subprocess.Popen(
["systemd-inhibit", "--what=sleep:idle",
"--why=diskOS flash in progress", "sleep", "infinity"])
except Exception:
self.proc = None
if self.proc is None and self.rep is not None:
self.rep.warning(
"could not auto-inhibit system sleep on this host - make sure your "
"computer will NOT sleep/suspend for the next ~90 minutes (a suspend "
"mid-flash aborts it; the device stays recoverable).")
return self
def __exit__(self, *exc):
if self.proc:
try:
self.proc.terminate()
self.proc.wait(timeout=5) # reap so no zombie / stray 'sleep infinity' lingers
except Exception:
try:
self.proc.kill()
except Exception:
pass
return False