Repository navigation
Runtime Helpers
Every executable the image installs outside /etc/init.d: the shell and Python helpers that boot services, the Moonraker component and each other call at runtime.
Source: NebulaOS-firmware at b95b156, scripts/build/overlay/. 58 helpers.
The image installs executables with ordinary system names that do not behave like the system commands they shadow. Assuming otherwise is the specific mistake this page exists to prevent.
| Name | What it actually is |
|---|---|
git |
a wrapper, first on Moonraker's PATH |
passwd |
redirects, because the image's /etc/shadow is read-only |
sftp-server |
relocates SFTP to /opt/printer_data |
sudo |
an adapter, not sudo |
supervisorctl |
a deliberate stand-in so Moonraker believes a supervisor exists |
Installed path is where the file lands on the printer - the overlay mirrors the root filesystem, so the path below the overlay directory is the path on the machine.
Called by is found by searching the whole repository for the helper's name, not inferred from it - documentation aside, because a page naming a helper is not a caller. A helper with nothing listed is not referenced by any code, test or manifest in this repository: it is invoked by something outside it, or it is dead weight. Either way that is a fact worth seeing, so it is stated rather than left blank.
Purpose is the file's own header comment, kept whole. These headers carry the reasoning behind decisions that look arbitrary from outside, and summarising them would lose exactly that.
Nothing here is hardware-verified. Every statement is read from source.
Called by: scripts/build/06-verify.sh, etc/init.d/S97nebulaos-display-qualified-apply, etc/init.d/S98nebulaos-display-sleep-wake-controller, etc/nebulaos-display-sleep-wake-controller.sh, usr/lib/nebulaos/python/nebulaos_osconfig/display.py, usr/libexec/nebulaos/nebulaos-backlight, ...and 1 more.
shellcheck shell=sh
NebulaOS backlight runtime control (GUI/service readiness mission,
2026-10-09). The ONE userspace writer of the display backlight. Used by:
- /usr/libexec/nebulaos/nebulaos-backlight (CLI; nebulaos-osconfig's
display domain calls it - that is how GUI brightness/sleep/wake land)
- S98nebulaos-display-sleep-wake-controller start (boot brightness restore)
INTERFACE: the kernel controller's standard backlight class device
(nebulaos_backlight_final_controller.c, "STANDARD BACKLIGHT INTERFACE"):
/sys/class/backlight/nebulaos_backlight/
brightness 0..4 0 off, 1/2/3 = 25/50/75 %, 4 = full (safe-on)
bl_power 0 on, 4 (FB_BLANK_POWERDOWN) off; the brightness
value is kept while off, so wake restores it
actual_brightness derived by the kernel from its state machine
The touch-wake watcher reads the touch driver's sysfs touch_down_count;
no display service uses or mounts debugfs.
OWNERSHIP. The kernel controller's state machine is the single source of truth; it verifies every transition and converges failures to safe-on (lit). The only userspace writers are this library and the S98 touch-wake watcher, which only clears bl_power, only while the backlight is off. The persisted level (/usr/data/nebulaos/display/display.env) belongs to nebulaos-osconfig.
SAFETY RULE: nothing here may leave the display worse than the kernel's boot-safe default; any failure stops and does nothing further. Sleep is refused unless an independent touch-wake path (the S98 watcher) is running.
Called by: scripts/build/06-verify.sh, etc/nebulaos-update-supervisor.sh, usr/bin/supervisorctl, usr/libexec/nebulaos/nebulaos-busy, tests/nebulaos-busy-tests.py, tests/update-supervisor-nebulascreen-tests.sh, ...and 1 more.
nebulaos-busy.sh - the fast shell variant of the G91 shared busy check (design _scratch/run16/G91-update-design.md v2.1, v3.1, v3.6), sourced by the supervisorctl shim, the update supervisor and its NebulaScreen component. It answers the two profiles that may need an answer without a Python start-up:
restart supervisorctl restart/stop of klipper (v3.1: < 2 s, runs before
`stop`): G95 (2026-10-22, design v3.7) - only a POSITIVE
answer refuses: PRINTING PAUSED ACTIVE from a live reply, or
the memguard markers while Klippy answers. No answer, a
timeout or an error (UNKNOWN) = allowed: a hung Klipper can
always be restarted from Mainsail.
screen the update supervisor's own NebulaScreen restarts: PRINTING
PAUSED ACTIVE, and UNKNOWN (a failed query against a live
Klippy) - the pre-G95 restart rule
recovery the supervisor's validation restarts and rollback transaction:
PRINTING PAUSED ACTIVE only; UNKNOWN counts only together with
the print-active marker (N3); never a lock (a rollback outranks
an update)
Every other profile (request, point_of_no_return, git) and the lock / memory / trial reasons live in nebulaos_osconfig.common.busy_state, CLI /usr/libexec/nebulaos/nebulaos-busy. Both read the same facts the same way; tests/nebulaos-busy-tests.py holds them to the same answers.
nebulaos_busy_fast PROFILE echoes the reason and returns 0 when busy, returns 1 (nothing echoed) when not busy
Order (v3.6 note 1):
1. Moonraker's /server/info klippy_state.
ready -> query print_stats, idle_timeout, heater targets,
PLR resume_in_progress in one request
error, shutdown -> not ready (Moonraker talks to Klippy: positive)
disconnected, startup, no answer -> the Klippy pid: not alive -> not
ready; alive -> ask Klippy's API socket (N1)
2. Klippy not ready: no print can run - not busy, whatever the memguard
markers say (R3: a crashed Klippy can always be restarted, S19/S27).
3. Otherwise the print state, activity, and the memguard markers
print-active / klipper-active (they only ever add busy).
BusyBox ash; wget is BusyBox's (-T timeout). Python only on the rare Klippy-socket path (Moonraker down or not connected while Klippy runs).
Called by: scripts/build/06-verify.sh, etc/init.d/S51nebulaos-camera-idle-controller, tests/nebulaos-camera-idle-controller-tests.sh, tools/hardware/nebulaos_capture.py, tools/hwcap/lv_cam.sh, tools/hwcap/pf_cam.sh.
NebulaOS camera idle-pause qualification controller (C2, pre- qualification mission Phase A7, 2026-07-31 - see docs/NEBULAOS_CAMERA_ USB_RT_SOURCE_ANALYSIS.md sec 18.10/18.16-18.20 and the original camera analysis sec 9-10 for the source-grounded rationale).
This is a distinct, independent capability from S50webcam's own supervisor loop (which only ever restarts a dead ustreamer process) - it never touches the ustreamer process itself, only its existing local /pause and /resume HTTP endpoints (already proven, source-confirmed to drive a real close()/reopen() cycle - see the camera analysis sec 9). Keeping this separate means a bug in this NEW, experimental controller can never take down the already-hardware-proven crash- recovery S50webcam provides.
Default state: C2 (idle auto-pause ON; S3, 2026-10-05). CAM-01R had made C0 the default on 2026-10-05 because the paused ustreamer re-encoded its 1080p placeholder every second (36 % of one CPU, 2026-10-04). CAM-05 caches that placeholder; measured on ke-dev the paused ustreamer then uses 0.10 % against 0.65 % unpaused-idle, the camera, hub and root port runtime-suspend, and a viewer resumes it in 0.06-1.4 s. A maintenance marker containing exactly "C0" opts out.
Viewer-activity signal: rather than depend on a live client-count query from ustreamer
(unconfirmed to exist from source - see the source analysis's own Variant F note) or naive
snapshot polling (a ?action= snapshot request is a single short-lived connection that
completes in well under a second and must NOT be mistaken for an ongoing viewer), this watches
for ESTABLISHED TCP connections on ustreamer's own local listening port (127.0.0.1:8080) via
/proc/net/tcp directly - no dependency on netstat/ss, which may not be present in this minimal
image. A genuine MJPEG stream connection is deliberately long-lived (multipart, kept open for
as long as the viewer watches); a snapshot's connection closes almost immediately. Sampling
once per poll interval and requiring a full, uninterrupted grace period of "no connection"
before pausing means a single stray snapshot-connection sample (if ever actually caught mid-
request) only delays the grace countdown by one tick - it can never cause an incorrect pause,
and the bias is always toward staying active longer, never toward pausing too eagerly.
Resume is retried with a simple bounded linear backoff and always wins over pausing: a fresh viewer connection immediately zeroes the idle counter and drives a resume attempt, with the controller staying in "paused" belief (and retrying resume on the very next tick) if the retries are exhausted, rather than ever assuming success it can't confirm.
Sourced by S51nebulaos-camera-idle-controller. Also sourced directly by tests/nebulaos-camera- idle-controller-tests.sh so there is exactly one copy of this logic, never a second one to drift out of sync.
Called by: manifests/dependencies.conf, scripts/build/04-cross-compile-app-stack.sh, scripts/build/lib/make-seed-archive.sh, etc/init.d/S05nebulaos-activate, etc/nebulaos-klipper-compose.sh, etc/nebulaos-update-supervisor.sh, ...and 12 more.
NebulaOS Phase 1 no-fork migration: the c_helper.so mtime invariant.
WHY THIS EXISTS --------------- Klipper decides whether to rebuild its C library by comparing MTIMES, not hashes. From official klippy/chelper/init.py at the qualified commit:
def check_build_code(sources, target): src_times = get_mtimes(sources) obj_times = get_mtimes([target]) return not obj_times or max(src_times) > min(obj_times)
def check_build_c_library():
srcdir = os.path.dirname(os.path.realpath(__file__))
...
if not check_build_code(srcfiles+ofiles+[__file__], destlib):
# Code already built
return destlib
...
do_build_code(cmd % (tempdestlib, ' '.join(srcfiles)))
where sources = SOURCE_FILES (.c) + OTHER_FILES (.h) + init.py, all inside klippy/chelper/, and target = klippy/chelper/c_helper.so.
If ANY of those is newer than the prebuilt .so, mainline shells out to gcc. This device has no toolchain, so do_build_code() raises and Klippy does not start - a total printer outage produced by a file timestamp. That is the whole reason NebulaOS does not need (and deliberately does not carry) the old fork's patch to this file: it is a firmware packaging problem, and packaging is where it is solved.
TWO HALVES, BOTH REQUIRED ------------------------- 1. Build time - scripts/build/04-cross-
compile-app-stack.sh calls chelper_enforce_mtime() as the LAST step over every staged copy of
the Klipper tree, then chelper_check_mtime() as a hard gate. The build fails rather than
shipping an image that will not boot. This is deliberate belt-and-braces: relying on "the .so
happens to be built after the sources were checked out" is exactly the build-order luck that a
cp -r (which does not preserve mtimes) or a later git read-tree -mu can quietly invert.
- Boot time - S05nebulaos-activate calls chelper_write_verdict() after composition and before
Klipper starts. Packaging correctness at build time does not survive everything the device can
do afterwards: a Moonraker-driven
git pullthat touches a chelper source, agit reset --hardduring rollback, a restored backup. So the invariant is re-checked against the tree that is actually about to run, and the verdict is written where the extension set's own preflight (extras/nebulaos_compat.py, via the manifest's chelper.platform_result_file) will read it.
The verdict file therefore makes a stale .so surface as a named, fail-closed preflight error at config load - "the platform reported the chelper prebuilt-library invariant as NOT satisfied" - instead of as a gcc crash part way through boot, or worse, a slow successful on-device compile that masks a real packaging bug.
WHY python3 (AND find -newer ONLY AS A FALLBACK)
------------------------------------------------ Klipper compares float mtimes
(os.path.getmtime), i.e. sub-second. BusyBox find -newer and ls -t compare WHOLE seconds
(verifier, 2026-10-17): a source written a few milliseconds after the library in the same
second passed this check and then made Klipper run gcc. So the comparison is done on
st_mtime_ns with the system python3 (on the image; the build host has one too), which is at
least as strict as Klipper's. Without python3 the old whole-second find -newer remains as a
fallback. The glob (*.c, *.h, init.py) is a SUPERSET of SOURCE_FILES+OTHER_FILES+file.
POSIX sh only - BusyBox ash. Sourced by S05nebulaos-activate, nebulaos-update-supervisor.sh, and 04-cross-compile-app-stack.sh.
Called by: etc/init.d/S97nebulaos-display-qualified-apply, etc/nebulaos-display-sleep-wake-controller.sh, etc/ota_marker.sh, usr/libexec/nebulaos-display-qualified-write, tests/nebulaos-display-qualified-tests.sh, tests/nebulaos-display-sleep-wake-controller-tests.sh, ...and 2 more.
NebulaOS display-qualification config: shared library for the persistent /usr/data/nebulaos/display-qualified.conf format, its atomic writer, its validator, and the apply logic for the two kernel-owned drivers this mission qualifies, through their sysfs interfaces:
- CONFIG_TOUCHSCREEN_NS2009_FINAL_QUALIFICATION
(ns2009_final_qualification.c): /sys/bus/i2c/devices/<bus>-<addr>/
nebulaos_touch/{mode,touch_down_count}, created at the driver's first
poll, before any init script runs.
- CONFIG_NEBULAOS_BACKLIGHT_FINAL_CONTROLLER
(nebulaos_backlight_final_controller.c): the standard backlight class
device /sys/class/backlight/nebulaos_backlight/.
Kernel and rootfs ship together in one image, so there is no debugfs fallback; debugfs stays in the kernel for manual diagnostics only and the image never mounts it.
Sourced by three callers: the boot-time apply script (etc/init.d/S97nebulaos-display-qualified- apply), the human-operator write helper (usr/libexec/nebulaos-display-qualified-write) that a real live-qualification session drives by hand over SSH, and this project's own offline test suite (tests/nebulaos-display-qualified-tests.sh) - one implementation, three consumers, so a fix or a field-list change only ever needs to happen in one place.
Config format: plain "key=value" lines, one per line - NOT JSON. Same reasoning S05nebulaos- activate's own header already documents for its own working file: this is a BusyBox ash environment with no JSON-aware tool confirmed present, so a JSON writer/parser would mean either hand-rolling one (a real bug surface for a file this safety-critical) or taking a new build dependency. Plain key=value lines are trivially parsed with sed/grep/case, which are already relied on everywhere else in this codebase (S99confirm-good, nebulaos-healthcheck.sh, etc).
SAFETY DEFAULT this whole file is built around: any parse/validate failure means "treat the file as absent" - the caller must then do nothing, leaving both kernel drivers in their own independently-safe boot defaults (touch poll-only, backlight boot-preserve). This library itself never applies a partial result - see ndq_validate()'s own all-fields-up-front design and ndq_apply_all()'s abort-on-first-failure sequencing.
Called by: etc/init.d/S98nebulaos-display-sleep-wake-controller, etc/nebulaos-display-qualified.sh, tests/nebulaos-backlight-tests.sh, tests/nebulaos-display-sleep-wake-controller-tests.sh.
NebulaOS display sleep/wake touch watcher - the persistent background daemon side of the deferred sleep/touch-wake fields in /etc/nebulaos-display-qualified.sh (see that file's own ndq_apply_deferred_fields() for how/when this gets started, and this mission's own scope: this file builds the WAKE side only - polling the touch driver's sysfs touch_down_count and clearing the backlight class device's bl_power when a real touch is observed while the backlight is off. It never triggers sleep itself; nebulaos-backlight (the GUI sleep path) does that.
Interfaces (both sysfs, paths from /etc/nebulaos-display-qualified.sh):
$NDQ_BACKLIGHT_SYSFS/actual_brightness 0 = off (asleep)
$NDQ_BACKLIGHT_SYSFS/bl_power written 0 to wake
$NDQ_TOUCH_SYSFS/touch_down_count decimal, increments per touch-down
Sourced by S98nebulaos-display-sleep-wake-controller. Also sourced directly by tests/nebulaos- display-sleep-wake-controller-tests.sh so there is exactly one copy of this logic, never a second one to drift out of sync - the same one-implementation-multiple-consumers pattern /etc/nebulaos-display-qualified.sh and /etc/nebulaos-camera-idle- controller.sh already both use.
Sources the display-qualified library for ndq_get_field/ndq_is_uint/ NDQ_CONFIG_FILE/NDQ_BACKLIGHT_SYSFS/NDQ_TOUCH_SYSFS - reused rather than re-parsing the config or re-declaring the sysfs paths a second time. Overridable so the offline test suite can point this at a fake library the same way every other caller of NDQ_LIB in this codebase already does.
Called by: scripts/build/06-verify.sh, etc/init.d/S97nebulaos-display-qualified-apply, etc/nebulaos-display-qualified.sh, etc/nebulaos-update-supervisor.sh, tests/nebulaos-display-qualified-tests.sh, tools/hardware/nebulaos_capture.py, ...and 1 more.
NebulaOS mutable-runtime mission, Phase 8: two-stage update verification.
Stage 1 (component health) is necessarily specific to what was just updated - see stage1_component() below, invoked with a component name. Stage 2 (full printer-stack health) is the same regardless of which component changed, and is safe to run standalone at any time (read-only queries only) - this is the part validated live in this pass, against the currently- running production Klipper/Moonraker, since it does not depend on Phase 7's mutable installs existing yet.
Usage: nebulaos-healthcheck.sh stage1 <klipper|moonraker|mainsail> nebulaos- healthcheck.sh stage2
stage2 exit codes (G91 C1 fix, 2026-10-22):
0 healthy, no print running
1 unhealthy (or Moonraker not answering)
3 a print is running (printing/paused) and every other check passed
4 a print is running and another check failed
A running print is never a health failure: the update supervisor defers its
judgement of the Klipper stack until the print has ended (3 and 4), and
never rolls back or restarts Klipper under it. Before, "printing" counted as
a failure, so a print started within ~4 min of an app update made the
supervisor roll back and restart Klipper under it (G91 inventory C1).
Called by: manifests/dependencies.conf, scripts/build/04-cross-compile-app-stack.sh, etc/init.d/S05nebulaos-activate, etc/nebulaos-update-supervisor.sh, tests/klipper-composition-tests.sh, tests/klipper-config-load-smoke-tests.py, ...and 5 more.
NebulaOS Phase 1 no-fork migration: runtime composition of official, unmodified Klipper with the NebulaOS Klipper extension set.
WHY THIS EXISTS --------------- Klipper's own module loader is a hard filesystem gate, not an import path. Printer.load_object() (klippy/klippy.py) does
os.path.exists(os.path.dirname(file) + '/extras/.py')
BEFORE it ever calls importlib. No PYTHONPATH entry, .pth file, namespace package, or wrapper launcher can satisfy that check - a file has to physically resolve at /klippy/extras/.py. See _project/missions/2026-08-phase1-klipper-no-fork- analysis.md section 6 for the empirical proof (raw importlib succeeds from an external directory while Klipper's own gate returns False on the same module).
But os.path.exists() follows symlinks, and git can be told to ignore a path per-clone via
.git/info/exclude, which lives inside .git/ and is never part of any working tree. So: symlink
each managed module into the Klipper checkout, list it in that clone's exclude file, and BOTH
checkouts stay content-pristine - git status --porcelain empty, git describe --dirty with
no -dirty, Moonraker reporting pristine/valid with zero anomalies. That is the entire
Architecture C mechanism, and it was proven end to end (216 tests identical composed-vs-forked)
before any of this shipped.
THE ONE REAL HAZARD, AND WHY VERIFICATION IS MANDATORY ------------------------------------------------------ If upstream Klipper ever ships a regular file at a path this project also manages, git SILENTLY overwrites the symlink with upstream's file - exit code 0, no warning, nothing in any status output. The extension is then shadowed and the printer runs upstream's code believing it runs ours. The exposed names are the vendored community ones (gcode_shell_command, virtual_pins) - exactly the kind of module mainline could adopt.
So a regular file at a managed destination is a HARD ERROR here. It is never overwritten, never worked around, never logged-and-continued. The extension manifest declares this contract explicitly as composition.require_symlink_resolving_inside_source, and extras/nebulaos_compat.py re-checks it from inside Klippy as a second layer.
CRASH SAFETY ------------ Every entry point is idempotent and every rebuild starts from a full teardown, because a half-completed prior run is a real state this device can reach (power loss, OOM kill, a reboot mid-boot). The states that can be left behind, and how the next run handles each:
* exclude lines written, symlinks not yet created -> teardown finds the
managed names in the exclude block and clears them; rebuild relinks.
* symlinks created, exclude lines missing -> teardown finds the
managed names in the manifest and removes the links; rebuild relinks.
(Cannot actually happen given the write order below, handled anyway.)
* symlinks dangling (extensions tree replaced) -> teardown removes
them by name; verification would have refused them regardless.
* marker written but link set later damaged -> verification fails
independently of the marker, forcing a rebuild.
* marker half-written (killed mid-write) -> the marker is
written atomically (tmp + mv) and last, so a partial one cannot exist;
a missing one simply reads as "needs rebuild".
The marker is therefore an optimisation ("can I skip the rebuild?"), never the safety property. Verification is the safety property, and it runs on every single invocation whether or not a rebuild happened.
WRITE ORDER (deliberate) ------------------------ Collision scan -> exclude block -> symlinks
-> verify -> marker. The exclude entries are written BEFORE the symlinks they cover so the
Klipper checkout is never dirty, not even transiently, at any instant during a rebuild. A
reader running git status mid-compose sees a clean tree.
Sourced by /etc/init.d/S05nebulaos-activate (boot activation) and /etc/nebulaos-update- supervisor.sh (post-update recomposition), the same shared-library convention as /etc/nebulaos- maintenance-gate.sh. Every function takes its paths as arguments so tests can drive it against fixture directories with no device involved.
POSIX sh only - this runs under BusyBox ash on the device. No jq, no stat, no bash-isms, no arrays.
Called by: etc/init.d/S04nebulaos-factory-seed, etc/init.d/S04nebulaos-migrate, etc/init.d/S56moonraker, etc/nebulaos-klipper-compose.sh, etc/nebulaos-update-supervisor.sh, tests/app-migration-tests.sh, ...and 16 more.
Shared maintenance-safety gate for S04nebulaos-factory-seed and S04nebulaos-migrate - both perform the exact same memory/IO-heavy work that must never run while a print is active, concurrently with a real update, or without memory-resilience swap active, and previously duplicated the identical gate logic independently in each script.
Final Baseline Closure mission (2026-08-08): extracted into one shared file specifically because a real bug was found live in the duplicated logic - fixing it once here, rather than needing the identical fix applied twice (and risking the two copies drifting apart, exactly the failure class this project's own conventions exist to avoid - see scripts/build/lib/make-seed- archive.sh's own header for the same reasoning applied to a build-time shared function).
Real bug found live during the Virgin Flash + Verification mission (2026-08-08): a stale update-transaction lock file, left over from BEFORE a persistent-state reset (an off-device backup + reset to simulate a virgin install), silently blocked every subsequent boot's factory- seed/migrate forever - neither script, nor anything else, ever cleared a stale lock, so the block was permanent until someone noticed and deleted it by hand.
Fix is deliberately narrow. On a genuinely UNSEEDED namespace ($APPS/klipper/.git absent - i.e. seed_git_app has never once completed here), an update-transaction lock is PROVABLY irrelevant: this project's real update mechanism (/etc/nebulaos-update- supervisor.sh) only ever creates a lock while updating an app that has ALREADY been seeded - if the app has never been seeded at all, no real update could ever have legitimately created this lock, so it can only be leftover debris and is safe to clear automatically. On an ALREADY-seeded namespace, a lock is left exactly as blocking as before - that case can genuinely mean a real, recent, failed update that nebulaos-update-supervisor.sh's own comments document as deliberately needing human review before retrying ("a human must clear $LOCKDIR/$name.lock") - this fix must never bypass that case, and does not.
Callers must already have their own log() function defined and their own $APPS/$LOCKDIR variables set before sourcing this file - this gate calls log() and reads those variables directly rather than taking them as parameters, so every existing log line keeps its own script's established "S04nebulaos-factory-seed: ..." / "S04nebulaos-migrate: ..." prefix unchanged.
Phase 1.5 hardware closure (2026-08-19): a lock left behind by a supervisor process that died mid-validate_component()/mid- validate_klipper_stack() - state.json still says "validating", nothing on disk it was validating has ever been proven - blocked BOTH this gate (every boot, forever, via the ALREADY-seeded branch below) AND S05nebulaos- activate's own validate_app() lock check. nebulaos-update-supervisor.sh already has its own boot-time reconcile for exactly this (reconcile_klipper_stack_on_boot/reconcile_stale_component_locks_on_boot), but that is a separate, LATER-started daemon - this gate runs from S04, early in boot, and cannot wait for it. Clearing a lock whose own state.json says "validating" here, before the blocking check below ever runs, is what actually unblocks S04/S05 on the SAME boot the interruption is discovered, rather than only on the boot after next.
Clears locks whose sibling state.json is in a TERMINAL state — one where no active update process is running and the supervisor itself cannot recover without a new boot-time pass. Only the LOCK FILE is removed; state.json is left for the supervisor to reconcile on its own later pass.
Terminal states cleared:
"validating" — supervisor died mid-validate (Phase 1.5 closure bug)
"interrupted" — supervisor restarted mid-transaction
"factory-fallback" — supervisor explicitly gave up (restore failed)
Phase 1.8 hardware qualification (2026-08-23): factory-fallback and interrupted locks blocked image migration on EVERY boot of candidate-001 AND candidate-002. The gate's original design preserved factory-fallback for "human must clear" semantics, but this created a permanent deadlock for image migrations — S04 runs before services, so no supervisor or human intervention can occur before the gate check. An image migration replaces the entire checkout, making any stale update state irrelevant. A malformed/missing/unreadable state file, or a state not in the terminal list, still leaves the lock exactly as blocking as before.
Called by: scripts/build/04-cross-compile-app-stack.sh, scripts/build/06-verify.sh, etc/init.d/S05nebulaos-activate, etc/init.d/S56moonraker, tests/moonraker-component-survival-tests.sh.
shellcheck shell=sh
Compose the NebulaOS [nebulaos] Moonraker component (and, since 2026-10-31, upstream's [timelapse]) into a Moonraker checkout (GUI/service readiness mission, 2026-10-09). Sourced by S05nebulaos-activate and S56moonraker.
The component lives on the read-only rootfs (/usr/lib/nebulaos/moonraker/ nebulaos.py). Moonraker only imports components from its own package, so the persistent checkout gets a symlink moonraker/components/nebulaos.py listed in that clone's .git/info/exclude - the same no-fork mechanism as the Klipper extras: Moonraker's update_manager still sees a pristine repository.
Why S56 runs it too: update_manager's update (git pull), rollback and soft recover (reset --hard / checkout) leave an excluded untracked file alone, but a HARD recover deletes the checkout and re-clones it - symlink and exclude entry gone. Every one of those paths ends in a Moonraker restart through /etc/init.d/S56moonraker (supervisorctl shim, the update supervisor's rollback and liveness restarts), so composing in S56 start() restores the component before Moonraker imports it. tests/ moonraker-component-survival-tests.sh replays those git operations.
Idempotent. An upstream file of the same name is never replaced (logged; the component is then unavailable). Never fails its caller: Moonraker loads [nebulaos] as an optional component.
Called by: scripts/build/06-verify.sh, etc/default/syslogd, etc/init.d/S01wifi, usr/lib/nebulaos/python/nebulaos_osconfig/network.py, usr/libexec/nebulaos/nebulaos-netapply, tests/log-level-and-clock-tests.sh, ...and 2 more.
shellcheck shell=sh
NebulaOS persistent hostname / IPv4 configuration - boot-time side (GUI/service readiness
mission, 2026-10-09). Sourced by S01wifi and by /usr/libexec/nebulaos/nebulaos-osconfig's live-
apply path (through nebulaos-osconfig-netapply), so the stanza a boot produces and the stanza
a live change produces come from one function.
Source of truth is /usr/data/nebulaos/network/osconfig.json, owned by nebulaos-osconfig (Python, validated, atomic). This file never parses JSON: the helper renders boot.env next to it - scalars only - and this library re-validates every value with case patterns. ANY doubt means DHCP with the default hostname, which is exactly the pre-mission behaviour plus the DHCP hostname option. A broken or hostile boot.env can therefore never stop the printer from getting an address.
NNET_HOSTNAME=<rfc1123 label>
NNET_MODE=dhcp|static
NNET_ADDRESS=a.b.c.d NNET_NETMASK=a.b.c.d NNET_GATEWAY=a.b.c.d
NNET_DNS="a.b.c.d a.b.c.d"
/etc/network/interfaces (read-only rootfs, DHCP) is left untouched and stays the fallback: S01wifi only uses the generated file when it was written.
Called by: etc/init.d/S02nebulaos-watchdog, etc/init.d/S49nebulaos-memguard, etc/init.d/S50webcam, etc/init.d/S54nebulaos-host-mcu, etc/init.d/S55klipper, etc/init.d/S56moonraker, ...and 7 more.
NebulaOS OOM priority policy (MEM-T07). Sourced by the service init scripts.
Each service gets its oom_score_adj at process birth: the value is written to a subshell's own /proc/self/oom_score_adj and start-stop-daemon is exec'd from it, so the daemon and every child inherit it. The previous pattern wrote /proc/$(cat pidfile)/oom_score_adj after start-stop- daemon -b returned, which raced the fork and, with a stale pidfile, could adjust an unrelated process.
Printer-critical processes are protected, optional UI/camera/update work is expendable. klipper_mcu is tiny and must never be the victim (-1000); klippy stays killable as a last resort (-900) so the kernel always has a sensible victim. nginx, dropbear and wpa_supplicant are Buildroot-owned scripts and are enforced by nebulaos-memguard's adj scan instead.
Called by: scripts/build/06-verify.sh, etc/init.d/S04nebulaos-migrate, etc/init.d/S45nebulaos-cleanup, etc/init.d/S99nebulaos-housekeeping, usr/lib/nebulaos/python/nebulaos_osconfig/cleanup.py, tests/extensions-branch-contract-tests.sh, ...and 7 more.
NebulaOS mutable-runtime mission, Phase 9: namespace-restricted, update-aware retention manager. Informed by SimpleAF's real, currently- shipping tools/cleanup-files.sh (pellcorp/creality, fetched 2026-07-26 - full findings in docs/NEBULAOS_RETENTION_POLICY.md sec
- but NOT a literal port: normal cleanup here is restricted to an explicit allowlist under /usr/data/nebulaos, adds a real active-print/partial-upload check before any emergency gcode deletion (a real gap in SimpleAF's own reference script - it has no such check at all), and never touches USB mounts.
Invoked by /etc/init.d/S45nebulaos-cleanup at boot (backgrounded, does not block boot - matches SimpleAF's own S45cleanup convention). Can also be invoked manually with --dry-run to preview without deleting anything.
Overridable for tests, same convention as S04nebulaos-migrate/flash- spare-slot.sh (Phase 2 final software closure mission, 2026-09-09 - needed the first time this script grew a real offline test suite).
Called by: artifacts/buildroot-halley5-v30-image/README.md, scripts/build/06-verify.sh, etc/init.d/S04nebulaos-factory-seed, etc/init.d/S04nebulaos-migrate, etc/init.d/S05nebulaos-activate, tests/app-migration-tests.sh, ...and 19 more.
Shared reader for /opt/nebulaos-seeds/seed-manifest.json, sourced by both S04nebulaos-factory- seed and S04nebulaos-migrate.
WHY THIS EXISTS. The seed archives are produced by the build with each component's local branch
forced to a specific name (scripts/build/lib/make-seed-archive.sh: git checkout -q -B "$active_branch"), and the on-device seeding functions then ASSERT that name before accepting
the extracted checkout. For the extension set that name is $KLIPPER_EXTENSIONS_BRANCH -
"production", the deployed-runtime branch - while the three on-device call sites hardcoded the
literal "main", which is the DEVELOPMENT branch. The assertion compares branch NAMES, so commit
equality between main and production never masked it: every seed was rejected, the extensions
app directory was never created, no app generation was ever recorded, and every subsequent boot
re-ran a full migration. (Audit finding F-06.)
The fix is not to re-hardcode the currently-correct literal in three places. That is the same drift class, re-armed. The image already ships the authoritative answer as DATA: 04-cross- compile-app-stack.sh writes "branch": "$KLIPPER_EXTENSIONS_BRANCH" into the per-component object of seed-manifest.json, from the same variable that drives make_seed_archive, in the same build run, into the same immutable /opt/nebulaos-seeds directory as the archive itself. Manifest/archive drift is therefore structurally impossible, and the branch assertion keeps doing its real job: proving the extraction produced the archive that shipped, not proving which branch the build chose.
The build's branch CHOICE is asserted separately and at the right layer, in source, by tests/extensions-updater-branch-strategy-tests.sh and tests/recovery-safety-tests.sh - not on a device after shipping.
Extracted into a shared file rather than duplicated, for the same reason maintenance_gate_ok() was (see $GATE_LIB): the identical logic living in two places in these two scripts is exactly how a real bug ended up needing the identical fix twice.
Print the "branch" value from one component object of a seed manifest.
Deliberately BLOCK-SCOPED. A flat key grep (S04nebulaos-migrate's own json_get(), which takes
head -1) would return the FIRST "branch" in the file - klipper's "master" - for every
component asked about. That collision is the most likely way to get this wrong, so it is tested
directly in tests/extensions-branch-contract-tests.sh.
Usage: seed_manifest_branch Prints the branch on success; prints nothing and returns 1 on any failure.
Called by: scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, usr/sbin/nebulaos-passwd, tests/nebulaos-shadow-tests.sh.
shellcheck shell=sh
Persistent root password (decision D7, 2026-10-10). The image's /etc/shadow is on the read-only rootfs and carries the default password every NebulaOS printer ships with; BusyBox passwd cannot change it (it writes /etc/shadow+ and renames it over). At boot S01persistent-datastore seeds a persistent copy once and bind-mounts it over /etc/shadow; nebulaos-passwd (and the passwd wrapper) rewrite that copy in place, so the change survives reboots and image updates. Sourced by S01persistent-datastore and nebulaos-passwd.
Called by: scripts/build/06-verify.sh, etc/init.d/S01wifi, etc/nebulaos-network.sh, tests/nebulaos-network-boot-tests.sh, tests/nebulaos-stable-mac-tests.sh.
NebulaOS stable per-device Wi-Fi MAC address derivation (pre-qualification mission Phase A3, 2026-07-31 - see docs/NEBULAOS_CAMERA_USB_RT_SOURCE_ ANALYSIS.md sec 18.11 for the root cause this fixes).
Real bug this exists to fix: the shipped brcmfmac43430-sdio.txt NVRAM carries generic, byte- identical-to-stock placeholder MAC-address fields (macaddr and il0macaddr, see that file's own lines 12/49). Mainline brcmfmac has its own hardcoded detection for exactly that known-bad template value (brcmf_default_mac_address in common.c) and, when it fires, generates a genuinely random MAC on every single boot - a real, source-proven explanation for this project's own documented DHCP/IP-address drift across reboots.
Why not fix this at the kernel/devicetree level instead: mainline brcmfmac does support a
standard nvmem-cells/local-mac-address DT override (eth_platform_get_mac_address(), firmware.c)
that would be resolved before this problem ever occurs - but this board's only candidate
hardware source for a per-chip value, the X2000 SoC's efuse@13540000 node, has no driver
bound to its actual compatible string ("ingenic,x2000-efuse" - the only in-tree efuse driver,
jz4780-efuse.c, matches "ingenic,jz4780-efuse" only) and defines no nvmem-cells at all. Wiring
that up would require assuming both a driver-compatibility match and a specific unique-ID byte
offset within the efuse's memory map, neither of which is confirmed anywhere in this repo or
verifiable without live hardware - exactly the kind of speculative pin/hardware assumption this
project's own established safety discipline (see docs/PIN_OWNERSHIP_MAP.md's BT GPIO example)
says not to wire up blind. A devicetree/bootloader fixup was rejected for the same reason:
unverifiable without hardware in Mode A.
What this uses instead: the eMMC's own CID (Card IDentification) register - a real, JEDEC- standard, factory-programmed 128-bit register already exposed by mainline's generic MMC core with zero kernel/DT changes needed (MMC_DEV_ATTR(cid, ...), drivers/mmc/core/mmc.c). It's a property of the physical eMMC chip itself, not the OS/rootfs, so it is identical across A/B rootfs/kernel slot switches, application updates, and rollback - exactly the stability this needs - and is available as soon as /sys is mounted, long before this script ever runs (you can't boot userspace at all without the eMMC already having been probed, since it IS the root storage).
Why override wlan0's MAC in userspace instead of "before brcmfmac probes": brcmfmac is compiled
in and probes during kernel boot, before any userspace script can run at all - genuinely
impossible to beat from userspace. What actually matters for network identity is the MAC in
effect when the interface is brought up and starts sending real traffic (DHCP discover,
association), not whatever the kernel briefly held immediately after probe - so this overrides
it via the completely standard ip link set address mechanism while the interface is down,
before wpa_supplicant/DHCP ever run. No kernel, devicetree, or bootloader change needed.
Sourced by S01wifi. Also sourced directly by tests/nebulaos-stable-mac-tests.sh so there is exactly one copy of this logic, never a second one to drift out of sync.
Called by: scripts/build/04-cross-compile-app-stack.sh, scripts/build/06-verify.sh, etc/init.d/S02nebulaos-namespace, etc/init.d/S04nebulaos-factory-seed, etc/init.d/S59nebulaos-update-supervisor, etc/nebulaos-chelper-preflight.sh, ...and 14 more.
NebulaOS mutable-runtime mission, Phase 8: rollback orchestration and transaction state machine (docs/NEBULAOS_UPDATE_AND_ROLLBACK_DESIGN.md).
Real constraint found while implementing this (not present in the design draft): this Moonraker version's update_manager (git_deploy.py/ app_deploy.py, confirmed directly against vendor/moonraker source) has NO pre/post-update command hook at all - it performs fetch, checkout, venv/requirement sync, and service restart entirely on its own the moment a user clicks "update" in Mainsail, with no way for external code to run in between "new version staged" and "new version activated". The design doc's Stage 1 (pre-activation) / Stage 2 (post- activation) split is therefore collapsed here into a single post-hoc validation that runs after Moonraker has already restarted the affected service - this script is an independent poller, not a Moonraker plugin, specifically so it keeps working even if a bad update breaks Moonraker itself.
Detection: git's own HEAD commit is polled per component. A change from the last-recorded value
means "an update just happened" (via Moonraker's own update flow, or a manual git operation -
this script does not care which). Rollback restores the previous known-good commit via git reset --hard, which needs no separate pre-update backup step for the source tree itself -
git's own history/reflog already holds it, which is why known_good_commit is tracked here
rather than snapshotting whole directory copies.
Restart safety (G91 C1 fix, 2026-10-22): Klipper is never restarted, reset, unmounted or recomposed under a print. The shared busy check (/etc/nebulaos-busy.sh, recovery profile: printing, paused, heaters or motion active; Klippy not ready = not busy) runs before every such step, and a busy printer DEFERS the step to a later poll - a state across polls, never a bounded wait whose result could be ignored. Health stage 2 reports a running print as "deferred" (exit 3/4), never as a failure, so a print started right after an app update is not mistaken for a broken stack. The whole rollback transaction (git reset, recompose, umount, restart) is deferred as one unit (state "rollback-pending") and re-run when idle. A Moonraker- only rollback may run during a print (N4): it never touches Klipper, and the screen needs Moonraker.
NebulaOS mutable-runtime closure mission (2026-07-27), Phase D: Moonraker source and virtualenv
are now one versioned release unit, not two independently-rolled-back things. Real gap this
closes: Moonraker's own app_deploy.py._update_python_requirements() installs into the EXISTING
venv in place whenever a commit changes requirements.txt - a plain git reset --hard on the
source alone does nothing to undo whatever pip already did to the venv. Rather than re-
implementing Moonraker's own in-place venv update logic (real risk of fighting/duplicating it),
this supervisor instead maintains its own full backup of the venv, refreshed every time a
(source, venv) pairing is confirmed healthy together, and always restores BOTH halves together
on rollback - since only one paired backup ever exists at a time and both halves are always
reset in the same step, there is no code path that can produce a mismatched pair.
NebulaOS Phase 1 no-fork migration (2026-08-17), Phase I: the validated, rollback-able unit for the Klipper side is now the PAIR (klipper_sha, extensions_sha), not Klipper alone.
Why the pair has to be the unit, concretely. NebulaOS runs official, unmodified Klipper with its own modules composed in from a separately updatable repository, and Moonraker can update either half independently - it has no cross-component ordering, no dependency gate, and no post-update hook. Two failure modes follow directly:
* new Klipper + old extensions: a Klipper update that touches any file
in klippy/chelper/ makes the shipped cross-compiled c_helper.so older
than a source, so Klipper's own mtime check decides to rebuild it with
gcc - which this device does not have. Klippy does not start. An API
rename lands here too: mainline already renamed
MCU.register_response() to register_serial_response() once.
* new extensions + old Klipper: the extension set's own preflight
refuses to load against a Klipper it was not qualified against, which
is the designed behaviour, but leaves the printer down until something
puts the pair back together.
So this file keeps ONE shared lock, ONE known-good record naming both commits, and ONE validation that either promotes both halves or rolls back both. A known-good pair is never recorded until both halves have actually passed - there is no code path that can record commit A paired with a snapshot belonging to a different commit, the same property moonraker_snapshot_env/moonraker_restore_env already give the Moonraker source+venv pairing below, and this is deliberately modelled on it rather than being a second, parallel transaction framework.
Recomposition is part of the transaction, not a side effect: after any change to either half, the symlink set is rebuilt and re-verified BEFORE Klippy is restarted, which is also when the collision guard and the c_helper.so mtime invariant get re-run. A stale library is caught here and refused, instead of surfacing as a gcc crash part-way through boot.
Paths and timings are overridable purely so the offline scenario tests in tests/klipper-stack- update-tests.sh can drive this file against fixture directories and stubbed services instead of a printer - the same seam convention S02/S04/S05 already use for SEEDS/APPS/SYSTEM/LOCKDIR/GATE_LIB. Real boot sets none of them, so every ":-" below resolves to the production value. The timings in particular exist because the real ones are measured against a printer that genuinely takes 15-25s to reconnect to its MCU, and a test suite cannot wait several minutes per scenario to learn that.
Called by: scripts/build/06-verify.sh, etc/init.d/S01wifi, tests/nebulaos-wifi-boot-wait-tests.sh.
NebulaOS Wi-Fi boot-association-wait qualification mode (pre- qualification mission Phase A6, 2026-07-31 - see docs/NEBULAOS_CAMERA_ USB_RT_SOURCE_ANALYSIS.md sec 18.11/18.17 for the source-grounded rationale).
S01wifi's current, real, already-hardware-proven behavior is a fixed sleep 2 between
launching wpa_supplicant and bringing the interface up for DHCP. The source analysis found this
plausible-but-unverified - not proven load-bearing, not proven arbitrary either - so this is an
EXPERIMENTAL alternative for later A/B testing, not a replacement: every build still defaults
to the exact original fixed-sleep behavior unless an explicit marker opts into the event-driven
path.
Variants: fixed (default/marker absent) - today's real, hardware-proven sleep 2 behavior,
byte-for-byte unchanged. event-driven - polls wpa_cli's own status for wpa_state=COMPLETED, up
to a bounded timeout, so a fast association doesn't wait the full fixed delay and a slow one
doesn't get cut short at 2s.
Never depends on internet reachability (only queries wpa_supplicant's own local control socket
via wpa_cli). Never starts a second wpa_supplicant or udhcpc - this only affects how long
S01wifi waits before its own existing single ifup wlan0 call, which itself remains completely
unchanged either way.
BOOT-02 (2026-10-05): the selector is ONE-SHOT. The marker is deleted
before its content is acted on, so the next boot is always back on the
fixed path; a marker that cannot be deleted is never honoured (it would
otherwise be sticky). The event-driven path is bounded and falls back to
the fixed path's own next step (the unchanged `ifup wlan0`) on timeout or
when wpa_cli is missing. Each boot writes what happened to
$NEBULAOS_WIFI_BOOT_WAIT_RESULT (tmpfs) for the hardware test to read:
MODE=fixed|event-driven OUTCOME=fixed|associated|timeout-fallback|
no-wpa_cli-fallback WAIT_START= WAIT_END= (uptime s) ASSOC_AFTER=<s>
IFUP_RC= IFUP_END= and IFUP=failed-reported when ifup returned non-zero.
Called by: etc/init.d/S01wifi, tests/klipper-config-load-smoke-tests.py, tests/nebulaos-namespace-ownership-tests.sh, tests/nebulaos-wifi-boot-wait-tests.sh, tests/nebulaos-wifi-migrate-tests.sh.
Phase 1.5 persistent-namespace mission (2026-08): safely moves the Wi-Fi credential file from its current location, /usr/data/nebulaos/wpa_supplicant.conf, into the new, more consistent namespace subdirectory /usr/data/nebulaos/network/wpa_supplicant.conf - WITHOUT ever risking the one credential file that is also this device's only real remote-access path (SSH rides the same wlan0 association).
Sourced by S01wifi, which is this file's only real caller. Exposes one function, nebulaos_wifi_migrate, which sets NEBULAOS_WIFI_CONF to whichever path the caller should actually hand to wpa_supplicant -i wlan0 -c afterward. Every code path below sets it to something usable; nothing here ever leaves a device with no config path chosen.
The five cases below match this mission's own governing brief exactly (case letters kept identical so the two documents can be read side by side): A existing device (NEW already there), B first migration (OLD real file, NEW absent), C virgin device (neither exists), D a write failure partway through migration, E an unexpected filesystem object at either path. Real tests: tests/nebulaos-wifi-migrate-tests.sh.
Called by: scripts/build/06-verify.sh, etc/init.d/S01wifi, usr/libexec/nebulaos-wifi-power-save, tests/nebulaos-wifi-power-save-tests.sh, tools/hardware/nebulaos_capture.py.
NebulaOS Wi-Fi power-save qualification mode (pre-qualification mission Phase A5, 2026-07-31 - see docs/NEBULAOS_CAMERA_USB_RT_SOURCE_ANALYSIS.md sec 18.8/18.17 for the source-grounded rationale).
NebulaOS's Wi-Fi chip (Cypress CYW43438, mainline brcmfmac) runs its default firmware PM_FAST
802.11 power-save mode unmodified today (no explicit override anywhere in this project) -
brcmfmac has no dedicated module parameter for this, the only real control surface is the
standard nl80211/iw set power_save path. This is NOT yet a production decision either way
(see the mission's own fixed-decisions list) - this is a controlled qualification mode for
later hardware A/B testing (Phase B4), never applied unless an explicit experimental marker is
present.
Variants: P0 (default/absent marker) - current firmware default (PM_FAST), no override applied
at all. This is what every build ships until a real hardware A/B test selects otherwise. P1 -
iw dev wlan0 set power_save off applied at boot.
Marker file lives under the persistent partition's maintenance area, never in normal user- facing configuration - this is an experimental qualification control, not a shipped feature, until Phase B7 freezes a decision.
Called by: scripts/build/06-verify.sh, etc/init.d/S04nebulaos-migrate, etc/nebulaos-retention.sh, usr/bin/nebulaos-recover, tests/config-materialization-tests.sh, tests/nebulaos-recover-tests.sh, ...and 1 more.
NebulaOS config materialization - shared library (Phase 2 final software closure mission, 2026-09-09; see docs/NEBULAOS_CONFIG_MATERIALIZATION.md for the full architecture writeup).
The immutable /etc/nebulaos/klipper/*.cfg tree (platform.cfg, machine.cfg, ..., beeper.cfg) is the canonical source this image ships, read-only, slot-owned. Klipper does NOT include it directly any more - it reads a MATERIALIZED, persistent copy at $PRINTER_DATA_CONFIG/nebulaos/, so that a device's active config always lives on the same writable storage as printer.cfg itself, browsable and (in principle) inspectable through Mainsail's ordinary Config Files view, the same way macros/, guppyscreen/ and firmware/mcu/ already are.
materialize_config_tree() is the one function both boot-time migration (S04nebulaos-migrate)
and the explicit nebulaos-recover config command call - never duplicated, so both code paths
get backup/verify/atomic- replace/generation-marker behavior identically.
Sourced, not executed - defines functions and reads the following
variables from the caller's environment, all overridable for tests, same
convention as every other shared lib in this project (see GATE_LIB):
NEBULAOS_KLIPPER_CFG_DIR - immutable source (default /etc/nebulaos/klipper)
PRINTER_DATA_CONFIG - persistent user config root
SYSTEM - persistent NebulaOS system state root
BACKUP_ROOT - $SYSTEM/migration-backups by convention
log - a log() function the caller already defines
Called by: usr/bin/nebulaos-mcu, usr/lib/nebulaos/python/nebulaos_osconfig/mcu.py, tests/nebulaos-mcu-cli-tests.sh, tests/nebulaos-osconfig-tests.py.
No header comment.
Called by: etc/init.d/S50nebulaos-mcu-guard, etc/nebulaos/mcu_application_identify.py, etc/nebulaos/mcu_restore.py, usr/bin/nebulaos-mcu, tests/mcu-application-identify-port-in-use-tests.py, tests/mcu-guard-tests.sh, ...and 1 more.
No header comment.
Called by: scripts/build/04-cross-compile-app-stack.sh, scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, etc/init.d/S58nebulascreen, etc/nebulaos-update-supervisor.sh, tests/nebulascreen-lib-tests.sh, ...and 2 more.
NebulaScreen (GuppyScreen renamed, Run 15) - boot-time helpers, sourced.
Stage 2a of the G82 + rename plan (_scratch/run15/G82-rename-architecture- review.md), wired into S01/S58 by stage 2b and used by the update supervisor (stage 3). Every function is best- effort: it logs and returns, it never makes a boot fail.
The A/B slots share /usr/data. The old image keeps using its own /usr/data/nebulaos/guppyscreen/guppyconfig.json, so the settings move by COPY, never by move or edit: a slot switch back finds its file untouched.
This file may be distributed under the terms of the GNU GPLv3 license.
Called by: FIRMWARE.md, scripts/build/06-verify.sh, etc/init.d/S00revert-safety, etc/init.d/S99confirm-good, opt/nebulaos/tools/plr_tombstone.py, usr/libexec/nebulaos/nebulaos-slot-swap, ...and 9 more.
Shared by S00revert-safety and S99confirm-good (FIRMWARE.md sec 21/23) - writes the ota
partition marker in the exact byte format both Creality's own stock ota_utils.sh
(mmc_write_str: echo $str > $dev) and ballaswag/ingenic-usbboot's swap_ota_partition()
(writes "ota:kernel\n\n" into a zeroed 512-byte buffer) use. Verified byte-for-byte via a local
regular-file simulation before ever being trusted on real hardware - tested output was an exact
match for the live device's own raw partition dump: "6f 74 61 3a 6b 65 72 6e 65 6c 0a 0a 00 00
...".
A bare echo -n "ota:kernel" (10 bytes, no trailing newline) would NOT match usbboot's own
read-check (strncmp(ota, "ota:kernel\n", 11)) and would fall into its "unexpected value"
branch unless --force-swap-ota is used - this exists specifically to avoid that mismatch.
G39 (2026-10-15): whether the slot a marker selects holds NebulaOS - its kernel's uImage name is a 6.x kernel (the other system's is 4.4). Read-only. Unreadable or anything else counts as not NebulaOS.
Not referenced anywhere else in this repository.
Interactive shell prompt (Run 23): user, host name and directory, so an SSH session says which printer it is on - "root@nebulaos-9e4f:~# " instead of Buildroot's bare "# ". \h follows a live host name change. BusyBox ash, FEATURE_EDITING_FANCY_PROMPT.
Called by: etc/udev/rules.d/91-usb-gcode-media.rules, usr/lib/nebulaos/python/nebulaos_osconfig/mcu.py.
OpenKE addition (USB/webcam stock-parity mission, 2026-07-26): mount/ unmount a USB mass- storage device under Moonraker's own gcodes root, so its files show up in GuppyScreen (and Mainsail/Fluidd) through the exact same file-listing path any other gcode already uses - see /etc/udev/rules.d/91-usb-gcode-media.rules for why this is the real integration point for this UI (GuppyScreen has no USB-media concept of its own; it only ever lists whatever Moonraker's /server/files/gcodes/ API reports).
Invoked by udev on add/remove of a real USB block device (sd[a-z] or sd[a-z][0-9] - never the internal eMMC, which is mmcblk0 and matched by neither pattern). $1 is the kernel device name (e.g. "sda" or "sda1"); ACTION is exported by udev itself.
Called by: tests/factory-clean-provision-tests.sh.
NebulaOS Clean-Update + Virgin Baseline mission, Phase 4 (2026-08-08): on-demand factory-clean provisioning. Archives (never destroys) NebulaOS-owned persistent state, then leaves the namespace exactly as a genuinely new device's would be - S04nebulaos-factory-seed re-seeds everything fresh from THIS image's own archives on the very next boot.
Deliberately lives under /opt (squashfs-resident, immutable) rather than under $NEBULAOS_ROOT itself - it must remain available and correct even if the persistent application tree it operates on is damaged or absent.
Scope, using the classification in docs/NEBULAOS_PERSISTENT_LIFECYCLE.md: ARCHIVED + RESET : apps/{klipper,moonraker,mainsail,guppyscreen} (IMAGE OWNED), envs/{klipper,moonraker} (GENERATED, cheaper to reseed than to hand-classify), system/* (NebulaOS's own generation/known-good/activation metadata - itself neither user data nor printer output) NEVER TOUCHED : printer_data/config (USER OWNED - printer.cfg, macros, moonraker.conf), printer_data/{gcodes,logs, database} (GENERATED, but the user's own print history/output, not this mission's concern), updates/backups/maintenance directories, and every stock partition (mmcblk0p5/p7) - this script never issues a single write to /dev/mmcblk0* of any kind, structurally, by only ever operating on paths under $NEBULAOS_ROOT.
Usage: factory-clean-provision.sh --archive-and-reset
Without that exact flag, prints this usage and does nothing - there is no accidental/implicit destructive path.
Called by: artifacts/buildroot-halley5-v30-image/halley5_v30.dts, scripts/build/04-cross-compile-app-stack.sh, scripts/build/05-final-build.sh, scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, etc/nebulascreen-lib.sh, ...and 5 more.
No header comment.
Called by: manifests/dependencies.conf, scripts/build/00-fetch-vendor-sources.sh, scripts/build/04-cross-compile-app-stack.sh, scripts/build/05-final-build.sh, scripts/build/06-verify.sh, scripts/build/nebulaos-post-build.sh, ...and 64 more.
No header comment.
Called by: scripts/build/04-cross-compile-app-stack.sh, scripts/build/06-verify.sh, etc/init.d/S50nebulaos-mcu-guard, etc/init.d/S54nebulaos-host-mcu, etc/nebulaos/klipper/machine.cfg, etc/nebulaos/mcu_application_identify.py, ...and 16 more.
nebulaos-mcu - MCU management CLI for NebulaOS
Subcommands:
status Show current MCU lifecycle state (read-only, from last boot check)
flash <f> Flash arbitrary firmware file to MCU (sets manual ownership)
managed Immediately restore pinned native firmware (re-enter managed mode)
The MCU lifecycle state machine (mcu_lifecycle.py) distinguishes:
SUPPORTED_HW_NATIVE_APP - candidate-001 running, healthy
SUPPORTED_HW_KNOWN_STOCK_APP - stock Creality detected, restore eligible
SUPPORTED_HW_UNKNOWN_APP - unrecognized app, never auto-restore
UNSUPPORTED_HW - wrong hardware, block everything
MCU_UNREACHABLE - serial unreachable, warn only
Ownership model:
/usr/data/nebulaos/mcu/managed contains "true" or "false".
- managed=true (default): NebulaOS manages the MCU. Boot guard restores
native if stock is detected.
- managed=false: User flashed custom firmware via `flash <file>`.
Boot guard skips restore for unknown applications but still restores
from known stock (stock recovery is always the right call).
nebulaos-mcu flash <file> sets managed=false (manual ownership). nebulaos-mcu managed
immediately restores native and sets managed=true.
Called by: scripts/build/04-cross-compile-app-stack.sh, scripts/build/06-verify.sh, etc/nebulaos-retention.sh, etc/nebulaos/config-materialize.sh, etc/nebulaos/moonraker/klipper-pin.conf, usr/bin/nebulaos-mcu, ...and 5 more.
nebulaos-recover - offline recovery tool for NebulaOS runtime components
Restores a mutable runtime component from the immutable baked copy on the read-only squashfs. Manual only, works offline, no automatic rollback.
Subcommands:
status Show activation state of all components
klipper Restore Klipper from its immutable seed tarball
moonraker Restore Moonraker from its immutable seed tarball
mainsail Restore Mainsail from the immutable squashfs copy
klipper-extensions Restore extensions from immutable squashfs
The immutable squashfs is the source of truth. Each recovery replaces the mutable /usr/data/nebulaos/apps/ with a fresh, verified copy, then requires a reboot for S05nebulaos-activate to re-bind.
Phase 2 overnight convergence mission (2026-09-09): klipper/moonraker recovery deliberately
does NOT read through /opt/klipper or /opt/moonraker any more, even though those paths are
commonly described elsewhere as "the immutable copy" - on a device where S05nebulaos-activate
has already bound a persistent copy over them (the normal, common case, exactly when recovery
is actually needed), they resolve to the very persistent copy being recovered, not the read-
only squashfs underneath. Proven live: deliberately corrupting the persistent klipper checkout,
then running the previous implementation (cp -a /opt/klipper/. "$dst"/), produced a "Recovery
complete" message while silently copying the corruption onto itself. Recovery for these two now
extracts directly from $SEEDS/.tar.gz (real, non-bundled git history, always present
on the squashfs, never bind- mounted), verified with the same branch/origin/clean-tree
discipline S04nebulaos-migrate's reseed_git_app() already established, before ever promoting it
over the current persistent copy. Mainsail has no such tarball (it is a plain static release
tree, not a git repo - see S04nebulaos-factory-seed's own comment on why) and unmounts its own
bind point first instead, to reveal the same real squashfs content the git-based approach reads
from a tarball.
Not the system
passwd- redirects, because the image's /etc/shadow is read-only.
Called by: FIRMWARE.md, artifacts/ustreamer/ustreamer, artifacts/webcam-overlay/usr/bin/ustreamer, build.sh, scripts/build/06-verify.sh, scripts/build/nebulaos-post-build.sh, ...and 12 more.
NebulaOS: root's password lives in a persistent copy of /etc/shadow (the image's file is read-
only), so passwd / passwd root run nebulaos-passwd. Anything else is BusyBox passwd as
before.
Not the system
sudo- an adapter, not sudo.
Called by: .github/workflows/tests.yml, scripts/build/06-verify.sh, tests/nebulaos-boot-display-tests.sh, tests/nebulaos-osconfig-tests.py, tests/workspace-control-privilege-guard-tests.sh, tools/hardware/nebulaos_capture.py, ...and 4 more.
NebulaOS sudo ADAPTER - not sudo (GUI/service readiness mission, 2026-10-09).
Moonraker's /machine/reboot and /machine/shutdown run sudo reboot / sudo poweroff
(machine.py exec_sudo_command) even when Moonraker itself is root, as every NebulaOS service
is. This image has no sudo, so both endpoints failed. This adapter makes exactly those root-to-
root calls work and nothing else:
- it is NOT setuid and refuses to run unless the caller is already root,
so it cannot grant any privilege;
- it accepts exactly `reboot`, `poweroff` or `halt` (optionally preceded by
-n), with no further arguments, and execs the BusyBox applet - which
signals init, so rcK stops services and unmounts /usr/data cleanly (G9);
- it never reads stdin (Moonraker only sends a password with -S when one
is configured; NebulaOS configures none);
- everything else exits 1, so Moonraker's check_sudo_access() correctly
reports that there is no general sudo.
Not the system
supervisorctl- a deliberate stand-in so Moonraker believes a supervisor exists.
Called by: scripts/build/06-verify.sh, etc/nebulaos-busy.sh, etc/nebulaos-moonraker-compose.sh, etc/nebulaos-update-supervisor.sh, etc/nebulaos/moonraker/nebulascreen-update.conf, opt/printer_data/config/moonraker.conf, ...and 8 more.
supervisorctl shim - by destinal this is a fake supervisorctl that provides just enough information for moonraker to think it's the real thing. good enough to list the names of services in moonraker.conf, to say whether they're running or not (with false pids and times) and to start and stop them by name, finding and calling the matching init scripts. installing: put this in in /usr/bin/supervisorctl and then in moonraker.conf in [machine] section, set "provider: supervisord_cli"
OpenKE addition (FIRMWARE.md sec 13): pulled byte-for-byte from the real, working install on this exact physical device's stock firmware (/usr/data/helper-script/files/fixes/supervisorctl, part of the open-source Guilouz/Creality-Helper-Script project - GPLv3). Stock uses this same shim (via moonraker.conf's [machine] provider: supervisord_cli) to let Moonraker's machine component work without systemd or a real supervisord daemon, neither of which exist on this Buildroot/BusyBox image either - this shim just drives our own /etc/init.d/S* scripts directly, the same as it does on stock. Confirmed compatible with this build's own init script naming (S55klipper, S56moonraker, etc.) and pid file convention (/var/run/klippy.pid for the "klipper" service).
Called by: manifests/dependencies.conf, scripts/build/06-verify.sh, usr/lib/nebulaos/python/nebulaos_osconfig/poweronlogo.py, scripts/package/assets/nebulaos-rtos-logo.txt, scripts/package/build-img.py, scripts/package/build-ingenic.py, ...and 9 more.
No header comment.
Called by: manifests/dependencies.conf, etc/init.d/S03nebulaos-blackbox, usr/lib/nebulaos/python/nebulaos_osconfig/logs.py, tests/blackbox-tests.sh, tools/hardware/nebulaos_capture.py.
nebulaos-blackbox (Run 24, 2026-11-06): why did the printer last restart, and what was it doing just before.
The 2026-10-26 and 2026-11-06 freezes left nothing behind: the system stopped dead, the hardware watchdog reset it ~60 s later and the last minute of every log never reached the eMMC. The kernel now keeps three things in 1 MB of RAM that survives a watchdog or panic reset (pstore/ ramoops, kernel halley5_v30.dts): its own log (console-ramoops-0), any panic/oops dump (dmesg-ramoops-N) and the heartbeat below (pmsg-ramoops-0).
collect once at boot (S03nebulaos-blackbox): read pstore and the SoC
reset status, decide why the last boot ended, keep the
evidence under logs/, empty pstore, show a notice after a
freeze or crash.
heartbeat forever, from S03nebulaos-blackbox: every 10 s one line into
/dev/pmsg0 (RAM, no eMMC wear) with load, memory and the
processes that used the most CPU since the last line,
real-time tasks included. SCHED_OTHER at nice 19, the lowest
(never takes CPU from Klippy), and never above Klipper's real-time work
(klipper_mcu FIFO 49, the IRQ threads): RT throttling keeps
it running beside a runaway real-time task, which its lines
then name (without PREEMPT_RT, hard-IRQ and softirq time is
counted to the task they interrupted, not to irq/N threads).
In a kernel stall nothing runs; then the time of
the last line is the evidence.
shutdown from S03nebulaos-blackbox stop (rcK): one "shutdown clean"
line into /dev/pmsg0 - a synchronous RAM write, so the next
boot knows this was an orderly restart.
The reasons (REASON= in /run/nebulaos/last-reset and reset-history.log),
checked in this order:
FREEZE (first) a panic dump from the watchdog pre-timeout handler
("nebulaos-freeze:", 1.0.2): the printer froze and the
kernel dumped its state 40 s later
CRASH a kernel panic or oops dump is in pstore
RESTART the heartbeat log ends in "shutdown clean" (rcK), or the
SoC scratch word holds the kernel's reboot signature 0x3535
(every software restart writes it, also `reboot -f`; a
watchdog fire never does), or the kernel log ends in
"reboot: Restarting system" (secondary: on PREEMPT_RT,
used before 1.0.2, that line could miss pstore)
POWER_OFF the kernel log ends in "reboot: Power down"/"System halted"
UNKNOWN no reset status from the kernel (an image without the black
box), or no evidence either way
FREEZE the previous boot's kernel log is in pstore, none of the
above, and the SoC says watchdog reset
POWER_ON nothing in pstore and a power-on reset (RAM lost: switched
on, or a power cut)
Never fails the boot: every step is best effort.
Called by: scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, usr/lib/nebulaos/python/nebulaos_osconfig/system.py, tests/control-manifest-current-tests.sh, tests/datafs-policy-tests.sh, tools/hardware/CONTROL_MANIFEST, ...and 2 more.
shellcheck shell=sh
G9 phase 1 (2026-10-07): /usr/data (ext4, no journal, stock layout) boot check policy and clean shutdown. Sourced by S01persistent-datastore; every path and tool is overridable so tests/datafs-policy-tests.sh can run the same functions against disposable ext4 images and fake mount tools.
Boot (ndf_precheck, before the mount): superblock clean -> mount rw, no fsck CLEAN not clean /
with errors -> bounded e2fsck -p (preen: only the automatic safe repairs) exit 0 -> mount rw
CHECKED_OK exit 1|2 (corrected) -> mount rw REPAIRED exit has 4 or 8 -> bounded FULL repair
e2fsck -fy: no error left -> mount rw AUTO_REPAIRED exit has 4 again -> mount READ-ONLY
UNREPAIRED exit has 8|16|32|128 -> mount READ-ONLY CHECK_FAILED still running at the bound ->
stopped, mount READ-ONLY TIMEOUT e2fsck/dumpe2fs missing -> mount rw as before TOOL_MISSING
Never mkfs.
G75 (Run 12, 2026-10-18): a journal on /usr/data. Pulling the power is how this printer is
switched off; without a journal that can leave the filesystem's bookkeeping half-written (Run
11 on ke-dev: an unattached inode the preen pass could not fix). With a journal the kernel
replays it at mount and the filesystem stays consistent - no check, no repair. So before a
read-write mount, a filesystem without one gets it, once (tune2fs -O has_journal,
NDF_JOURNAL_MB). Only on a filesystem that just checked or repaired clean (or was clean); a
failure is logged and the boot goes on without a journal. The Creality slot mounts the same
partition: has_journal is a "compatible" ext4 feature and every ext4 kernel has journal support
(EXT4_FS selects JBD2), so its 4.4 kernel mounts it.
Run 11 (2026-10-17, ke-dev): a power cut left one unattached inode that the preen pass may not
fix ("RUN fsck MANUALLY"). The old policy then mounted /usr/data read-only and waited for a
supervised repair - which a printer owner has no way to run, so the printer did not come up.
The printer must always boot: the full repair (-fy) is what a supervised repair would do
(orphans go to lost+found, bitmaps and counts are corrected). The preen output stays in
$NDF_CHECK_OUT, the repair's in $NDF_CHECK_OUT.repair. Read-only remains only for what even the
full repair cannot fix; the boot then still continues (rcS gives every script stdin from
/dev/null, so nothing waits for a console answer). The result is in $NDF_STATE (tmpfs), on the
console and in syslog, and - for writable mounts - appended to the persistent diagnostics log.
Shutdown (ndf_shutdown, last in rcK): leftover users of the filesystem (services are already stopped in reverse order by rcK) get TERM, a bounded wait, then KILL; the eMMC swap tier is turned off; mounts living on or under the filesystem are unmounted deepest first; then /usr/data is unmounted, or at least remounted read-only. Either leaves the superblock clean, so the next boot needs no check. Result: CLEAN | CLEAN_RO | BUSY | FAILED.
Called by: etc/nebulaos-display-qualified.sh, tests/nebulaos-display-qualified-tests.sh.
Human-operator helper for writing /usr/data/nebulaos/display-qualified.conf after a real, physical, live-qualification session on the device - this script is the "clearly-documented manual command sequence" that later, separate phase is expected to drive by hand over SSH. It does not itself perform any qualification - it only records the operator's already- obtained results, atomically and validated.
Usage: nebulaos-display-qualified-write [KEY=VALUE ...]
Any field from /etc/nebulaos-display-qualified.sh's NDQ_FIELDS list not given a KEY=VALUE here falls back to its documented default - UNQUALIFIED for anything that would otherwise gate a hardware apply step, or the specific inert default for the sleep/touch-wake fields (sleep_enabled=0, touch_wake_enabled=0, touch_wake_mode=disabled, first_touch_policy=not- implemented) - see ndq_default_for_field(). This is what makes "never persist a value that wasn't actually live-qualified" automatic rather than something the operator has to remember every time.
touch_wake_mode must always agree with touch_wake_enabled (polling<->1, disabled<->0) - ndq_validate() rejects a rendered config where they disagree, so this helper will refuse to write one.
Example - a full qualification session that tested irq-assist touch and a 50% PWM backlight:
nebulaos-display-qualified-write \ qualification_complete=1 \ touch_mode=irq-assist
touch_trigger=pendown-gpio-edge \ release_stable_samples=3 idle_safety_poll_ms=250
touch_fallback_enabled=1 \ backlight_mode=pwm pwm_channel=0 pwm_period_ns=20000
pwm_polarity=normal \ pc22_usage=unused \ safe_brightness=50 minimum_brightness=25 off_value=0
\ sleep_enabled=0 touch_wake_enabled=0 first_touch_policy=not-implemented
qualified_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
Example - touch only, backlight qualified as the plain fixed-safe-on state (no PWM testing performed, so every pwm_*/safe_brightness field is correctly left UNQUALIFIED rather than guessed):
nebulaos-display-qualified-write \ qualification_complete=1 \ touch_mode=poll-only touch_trigger=poll \ release_stable_samples=3 idle_safety_poll_ms=250 touch_fallback_enabled=1 \ backlight_mode=fixed-safe-on \ qualified_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
Example - sleep capability qualified (backlight-only sleep + polling touch-wake), fixed-safe-on backlight, no PWM tested. off_value is correctly left UNQUALIFIED - sleep here is GPC0-GPIO- off, not a PWM duty, so there is nothing to record there even though sleep_enabled=1:
nebulaos-display-qualified-write \ qualification_complete=1 \ touch_mode=poll-only
touch_trigger=poll \ release_stable_samples=3 idle_safety_poll_ms=250 touch_fallback_enabled=1
\ backlight_mode=fixed-safe-on \ sleep_enabled=1 touch_wake_enabled=1 touch_wake_mode=polling
qualified_at_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
To withdraw a qualification (revert to "nothing has been tested" without deleting the file -
e.g. after a hardware change that invalidates earlier results): run with no
qualification_complete=1 override, i.e. just nebulaos-display-qualified-write with no
arguments, or explicitly nebulaos-display-qualified-write qualification_complete=0.
Renders the FULL config via ndq_render_config (the same renderer the offline tests use), validates the rendered result with the EXACT same ndq_validate() the boot-time apply script (S97nebulaos-display-qualified- apply) will later run against it, and only writes to disk if that validation passes - so a config this helper accepts is guaranteed to be one the apply script will actually act on; there is no way for this tool and the apply script to silently disagree about what is valid. Refuses to write anything at all if validation fails, leaving any previous config completely untouched.
Called by: etc/init.d/S49nebulaos-memguard, etc/nebulaos-oom-policy.sh, etc/nebulaos-update-supervisor.sh, tests/diskswap-boot-policy-tests.sh, tests/log-level-and-clock-tests.sh, tests/nebulaos-memguard-tests.sh, ...and 2 more.
nebulaos-memguard - bounded memory-pressure supervisor (MEM-T12).
Keeps the printer-critical processes (klipper_mcu, klippy) away from the kernel OOM killer: optional work is refused or shed first, and the global OOM killer is the last resort, not the control mechanism.
This file is the core: it tracks memory state and print/maintenance state, publishes markers under /run/nebulaos for other components, enforces the OOM priority table (/etc/nebulaos-oom- policy.sh) every 5 s as a backstop to the birth-time values, and logs. klippy and klipper_mcu are never acted on.
BusyBox ash. /proc and /sys files are read with the read builtin, so the 1 s tick forks
nothing; only the 5 s Moonraker queries fork (wget).
Signals (1 s tick unless noted):
MA MemAvailable, kB /proc/meminfo
ZU ZL zram mem_used_total, mem_limit, bytes /sys/block/zram0/mm_stat 3,4
SOME10 FULL10 PSI memory avg10 (integer %; 0 without CONFIG_PSI)
PRINT Moonraker print_stats.state printing|paused (5 s; a failed query
keeps the previous value - a dead Moonraker never reads "idle" -
unless Klippy is positively not ready: Moonraker says error or
shutdown, or no Klippy process runs (G91 R3: a crashed Klippy
must not leave a sticky "printing" behind)
ACT G91 N6, same query: idle_timeout "Printing" or a heater target
above 0 in any of the last 3 samples (~15 s; idle_timeout drops
to Ready 0.5 s after the motion queue drains)
MAINT update lock present, or Moonraker update_manager busy (5 s)
Markers (/run/nebulaos): print-active, klipper-active, maintenance-active, memguard/state
Test seam: NEBULAOS_MEMGUARD_NO_AUTORUN=1 sources the functions without running; MG_PROC/MG_SYS/MG_RUN/MG_LOCKS/MG_LOG redirect every path.
Called by: scripts/build/06-verify.sh, etc/nebulaos/klipper/camera.cfg, tests/nebula-camera-macro-tests.sh, tests/nebulaos-seed-mainsail-macros-tests.py.
Nebula Camera controls from a Klipper macro (camera.cfg, Run 23, 2026-10-29: the same settings as the screen's Display > Camera page, in Mainsail's Camera macro group). One fixed verb + value, checked here, then nebulaos-osconfig camera (which stores the setting, applies it to a plugged Nebula Camera, and refuses a reset while the printer is busy).
nebulaos-nebula-camera night auto|color|bw nebulaos-nebula-camera flicker off|50|60 nebulaos- nebula-camera restart | defaults
Called by: scripts/build/06-verify.sh, etc/init.d/S57nebulaos-camera-seed, usr/libexec/nebulaos-seed-mainsail-macros, usr/libexec/nebulaos-seed-timelapse, tests/nebulaos-seed-camera-tests.py, tests/nebulaos-seed-mainsail-macros-tests.py.
NebulaOS Moonraker update/camera implementation mission (2026-07-27, see docs/NEBULAOS_MOONRAKER_UPDATE_AND_CAMERA_ANALYSIS.md). Seeds exactly one database-backed ("source": "database") default webcam for this project's one supported printer (Creality Ender-3 V3 KE / Nebula Pad) the first time Moonraker is confirmed reachable, using Moonraker's own supported HTTP webcam API - never direct SQLite. This exists specifically because a config- defined webcam (moonraker.conf's old [webcam Nebula] section) is permanently un-editable/un- deletable via Mainsail by Moonraker's own design (WebcamManager rejects POST/DELETE for any webcam whose source == "config") - a database-backed camera behaves like any normal user-added camera instead.
Invoked (backgrounded) by /etc/init.d/S57nebulaos-camera-seed, same non-blocking convention as S45nebulaos-cleanup. Uses only the Python standard library (urllib, json) - no dependency on Klipper/Moonraker's own venvs, so the plain system /usr/bin/python3 is sufficient and used by the init script.
Deliberately a single, self-contained script rather than folding this into S04nebulaos-factory- seed's own git/venv seeding: this needs to run AFTER Moonraker is actually answering HTTP requests (a runtime, service-level dependency), not at the namespace/factory-seed stage (a filesystem-level one) - mixing the two would make S04's own already-complex pause/resume dance around memory-sensitive operations harder to reason about for no real benefit.
Testable in full offline: see tests/nebulaos-seed-camera-tests.py, which imports this module directly and calls run() with fake fetch/post/sleep/ log callables and a temp-directory marker path - no real Moonraker, no real device, no real network.
Called by: scripts/build/06-verify.sh, etc/init.d/S57nebulaos-mainsail-macros-seed, usr/bin/nebulaos-recover, tests/nebulaos-recover-tests.sh, tests/nebulaos-seed-mainsail-macros-tests.py, tools/hwcap/lv_g8.sh.
NebulaOS Phase 2 macro-cleanup/Mainsail-grouping mission (2026-09-04, extended 2026-09-06 - RC2 overnight closure).
Seeds NebulaOS's own default Mainsail macro groups (Moonraker database namespace "mainsail", key "macros.macrogroups.") the first time Moonraker is confirmed reachable, migrates any pre-existing groups from their old underscore-style IDs to the current hyphenated ones, and defaults a genuinely untouched install's macros.mode to "expert" - same never-block-boot, never-trust-HTTP-2xx-alone, verify-after-write, atomic-marker conventions as nebulaos-seed- camera (see that script's own header for the fuller rationale; this one is deliberately structured identically).
Why database keys, not Mainsail's own ".../default.json" fresh-install mechanism: that
mechanism is real (Mainsail's own gui/initDb fetches GET
/server/files/config//default.json on a genuinely fresh install), but (a) it only ever runs
once, on an install where the mainsail namespace does not exist AT ALL yet - it cannot add a
new NebulaOS group to an ALREADY-INITIALIZED existing install, which is exactly the safe-
migration case this mission also requires, and (b) the exact config-subdirectory path segment
Mainsail's own minified bundle uses for could not be confirmed with confidence from static
analysis of the vendored dist (see the Phase 2 overnight research report). Posting directly to
Moonraker's own, already-documented /server/database/item API - the same mechanism Mainsail's
own code uses internally for every group edit a user makes by hand - works identically for a
genuinely fresh install and an existing one, with no unconfirmed path dependency, and is
exactly the same pattern this project already uses for the default webcam (nebulaos-seed-
camera).
2026-09-06 RC2 finding (read straight from the pinned Mainsail v2.18.2 vendored dist,
vendor/mainsail-dist/dist/assets/index-*.js - not guessed): dashboard-layout seeding is NOT
needed and was never missing. Mainsail's own gui/getPanels getter auto-synthesizes a {name: "macrogroup_<id>", visible: true} entry for every id returned by macros/getAllMacrogroups
whenever macros.mode === "expert" - the very first render after a group exists and mode is
expert is enough for the panel to appear on every viewport's first layout slot. The 2026-09-04
caution about "the exact object shape ... could not be confirmed" was reasonable at the time
but is superseded by this direct source read: no separate dashboard.desktopLayout1/mobileLayout
write is required, or implemented here.
2026-09-06 RC2 finding, the real (narrower) blast radius of the underscore-ID bug:
getPanelName() in that same bundle resolves a panel's display title via
panelName.split("_")[1] - for "macrogroup_nebulaos_calibration" that yields "nebulaos" (not
"nebulaos_calibration"), which fails macrogroups.find(g => g.id === "nebulaos") and falls
back to a generic "Macrogroup" label in the Settings > Dashboard "hide panels" list and the
icon lookup. The panel's own card header text still reads correctly (it renders group.name
directly, e.g. "Calibration"), and the panel-visibility filter uses a different, correct
name.slice(11) extraction - so underscore IDs do not actually stop the six group panels from
appearing or working; they only mislabel one secondary settings-list entry per group. Renaming
to hyphenated IDs, as the mission requires regardless, removes this defect entirely (an id with
no further "_" makes split("_")[1] recover the same string slice(11) does).
Scope deliberately does NOT include dashboard-layout seeding for the reason given above (it is unnecessary, not merely deferred).
Testable in full offline: see tests/nebulaos-seed-mainsail-macros-tests.py.
Called by: scripts/build/06-verify.sh, etc/init.d/S57nebulaos-timelapse-seed, etc/nebulaos/moonraker/nebulaos.conf, tests/nebulaos-seed-timelapse-tests.py.
No header comment.
Called by: scripts/build/06-verify.sh, etc/nebulaos/klipper/camera.cfg, tests/camera-quality-presets-tests.sh.
OpenKE - apply an end-user-selected camera quality preset (LOW/MED/HIGH) and restart the camera pipeline to pick it up.
Camera quality presets mission (2026-08-04): writes the marker file S50webcam reads (see that script's own header comment for why LOW/MED leave fps uncapped rather than requesting a specific rate) and restarts it the same way reload_camera.py does - S50webcam already owns discovering the real UVC node and managing ustreamer's lifecycle, a plain restart is the correct, complete way to apply a new preset.
Called by: scripts/build/06-verify.sh, etc/init.d/S04nebulaos-factory-seed, etc/init.d/S05nebulaos-activate, tests/app-migration-tests.sh, tests/extensions-branch-contract-tests.sh, tests/factory-seed-git-tests.sh, ...and 10 more.
nebulaos-venv-lib.sh - shared persistent-venv compatibility helpers.
Sourced by /etc/init.d/S04nebulaos-factory-seed (which owns creating and replacing these environments) and by /etc/init.d/S05nebulaos-activate (which only decides whether to bind-mount one). It deliberately lives in ONE place: two copies of an init-time predicate that must agree is the same hazard 02-configure-buildroot.sh documents at length for overlay files, where an older generation sorted first and silently won.
Defines: venv_is_usable, recover_torn_venv, swap_venv_into_place, venv_validated_usable, venv_validation_key, venv_validation_reset.
Callers may define log(); if they do not, a no-op stand-in is used so this file is safe to source from anywhere.
Called by: scripts/build/06-verify.sh, etc/init.d/S01wifi, etc/nebulaos-wifi-power-save.sh, tests/nebulaos-wifi-power-save-tests.sh, tools/hardware/nebulaos_capture.py.
NebulaOS Wi-Fi power-save qualification CLI (pre-qualification mission Phase A5, 2026-07-31). Thin wrapper around /etc/nebulaos-wifi-power-save.sh for interactive/scripted use during Phase B4's hardware A/B testing. Never applied automatically outside of what /etc/init.d/S01wifi already does at boot per the current marker state - this CLI only lets an operator inspect or change that marker, and immediately re-apply it without a reboot.
Not the system
git- a wrapper, first on Moonraker's PATH.
Called by: .github/workflows/build-environment.yml, .github/workflows/tests.yml, .gitignore, ACKNOWLEDGEMENTS.md, ANALYSIS.md, FIRMWARE.md, ...and 215 more.
NebulaOS git wrapper (MEM-T08). First on Moonraker's PATH (S56moonraker), so
update_manager's git runs through it; nothing else uses this directory.
- every git it runs starts at oom_score_adj 800: index-pack is the heaviest
transient on this system and must be the first OOM victim;
- while a print is active, a network fetch is a no-op success (the repo
stays valid with its last known refs) and pull/clone are refused;
- under memory pressure (memguard P1+) fetch/pull/clone are refused;
- G91 step 7 (design v2.4/v3.4): every repo-changing command (pull, clone,
checkout, reset, merge, rebase, clean) asks the shared busy check under
the `git` profile (printing, paused, heating/moving, calibrating, an
image/MCU job, low memory, a live Klippy whose state cannot be read; not
Moonraker's own update) and is refused while busy: the reason on stderr
(Moonraker shows it in the update dialog for pull/clone), in syslog, and
as a "!! NebulaOS refused: ..." line in Mainsail's console. This covers
/machine/update/full and paused prints, which Moonraker does not check.
G107: the console line at most once per minute per reason (below).
fetch only downloads: it stays a no-op under a print, as before.
/usr/bin/git itself is untouched.
Called by: scripts/build/06-verify.sh, etc/init.d/S97nebulaos-display-qualified-apply, etc/init.d/S98nebulaos-display-sleep-wake-controller, etc/nebulaos-backlight.sh, etc/nebulaos-display-qualified.sh, etc/nebulaos-display-sleep-wake-controller.sh, ...and 5 more.
nebulaos-backlight - CLI over /etc/nebulaos-backlight.sh (GUI/service readiness mission, 2026-10-09). Called by nebulaos-osconfig's display domain with argv only. See the library header for ownership and safety rules.
status | set <25|50|75|100> | sleep | wake | restore
Exit codes follow the library: 0 ok, 1 invalid, 2 failed, 3 off (set: level stored, applied on wake), 4 sleep refused (no touch-wake path).
Called by: scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, etc/init.d/S01splash, tests/nebulaos-boot-display-tests.sh, tools/hardware/nebulaos_capture.py, tools/hwcap/gs_bootmode.sh, ...and 1 more.
nebulaos-boot-display - userspace boot display (GUI/service readiness mission, 2026-10-09). Shell only: Python never runs on the boot path.
draw S01persistent-datastore, right after /usr/data is mounted: show
the configured mode as early as possible (G3)
boot S01splash: show it unless "draw" already did, then schedule the
late re-render
The "console" mode (a text boot log on the LCD) was removed (D10, 2026-10-11): once the framebuffer console had taken over the display, GuppyScreen's start deadlocked in the kernel. A saved "console" shows the logo. The kernel no longer has a framebuffer console at all.
Every step is best effort and bounded; any doubt means "do nothing" and the screen stays as the boot loader left it. The backlight is never touched here (the display-qualification/backlight layer owns it).
Called by: artifacts/buildroot-halley5-v30-image/README.md, artifacts/buildroot-halley5-v30-image/buildroot.config, br2-external/board/nebulaos-x2000/.gitignore, br2-external/configs/nebulaos_x2000_defconfig, scripts/build/02-configure-buildroot.sh, scripts/build/06-verify.sh, ...and 16 more.
nebulaos-busy - the G91 shared busy check for shell callers and SSH.
nebulaos-busy [--profile PROFILE] [--holder TOKEN] [--refuse] nebulaos-busy [--profile PROFILE] --lock NAME --exec CMD [ARG...]
PROFILE: request (default) point_of_no_return git restart recovery screen.
Plain: prints BUSY= (PRINTING PAUSED ACTIVE CALIBRATING UPDATING LOW_MEMORY UNKNOWN) or BUSY=no, then MESSAGE= and KLIPPY=. exit 0 = not busy (go ahead), 1 = busy, 64 = usage.
--refuse (G91 step 6, for the CLI tools): prints nothing on stdout; busy -> "NebulaOS refused: ()" on stderr and exit 3 (the osconfig refusal code), not busy -> exit 0.
--lock NAME --exec CMD...: takes the maintenance lock (token NAME-), runs the check with that token excluded, and on "not busy" execs CMD with the lock still held (the fd is inherited; it dies with CMD) and NEBULAOS_MAINTENANCE_HOLDER= in its environment, so CMD can re- check at its own point of no return (--holder "$NEBULAOS_MAINTENANCE_HOLDER"). Another holder -> UPDATING. Refusals as --refuse (exit 3, nothing run). nebulaos-mcu, nebulaos-recover and flash-spare-slot.sh re-exec themselves through this unless --force.
The same check as nebulaos_osconfig.common.busy_state (the [nebulaos] component and the osconfig verbs); /etc/nebulaos-busy.sh is the fast shell variant of the restart/recovery/screen profiles (supervisorctl shim, update supervisor).
Called by: scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, usr/lib/nebulaos/python/nebulaos_osconfig/cleanup.py, usr/lib/nebulaos/python/nebulaos_osconfig/system.py, tests/creality-cleanup-list-tests.sh, tests/nebulaos-osconfig-tests.py, ...and 3 more.
nebulaos-clean-reset - the boot half of the clean NebulaOS reset (S2, 2026-10-11). Sourced by S01persistent-datastore right after /usr/data is mounted and before anything uses /usr/data/nebulaos.
When the flag written by nebulaos-osconfig system clean-reset is present, the whole NebulaOS
namespace is removed: printer.cfg and calibration, Moonraker's database and history, Wi-Fi
networks, network settings, the root password, the boot screen, GuppyScreen's settings. The
rest of the boot then seeds everything from this image, exactly as on a new printer.
Kept: the device's identity - the Wi-Fi MAC seed (so the router keeps the same address) and the SSH host keys (so SSH clients and the Hardware Agent still recognise the printer, G1) - and everything outside /usr/data/nebulaos: the other boot slot's files and the shared G-code folder (/usr/data/printer_data/gcodes).
Left behind for afterwards (G2, G28): a line in /usr/data/.nebulaos-clean-reset.log (outside the wiped folder, so the reset itself stays on record) and the marker /usr/data/nebulaos/.after-clean-reset, which makes the screen say once that Wi-Fi has to be connected again (cleared by system ack-clean-reset).
Crash-safe: the namespace is first renamed (one atomic step), then deleted; a power cut in between leaves a .nebulaos-wiping-* directory that the next boot deletes. The flag is removed only after the rename, so an interrupted reset repeats harmlessly. Nothing happens on a read- only /usr/data.
Called by: scripts/build/06-verify.sh, usr/lib/nebulaos/python/nebulaos_osconfig/network.py, usr/lib/nebulaos/python/nebulaos_osconfig/wifi.py, tests/nebulaos-network-boot-tests.sh, tests/nebulaos-osconfig-tests.py.
nebulaos-netapply - live side of the persistent network configuration (GUI/service readiness mission, 2026-10-09). Called only by nebulaos-osconfig (which holds the osconfig lock and has already written a validated boot.env). Uses /etc/nebulaos-network.sh, the same library S01wifi uses at boot, so a live change and the next boot produce the same stanza. Never touches wpa_supplicant: association is independent of addressing.
apply re-address wlan0 from boot.env and verify
exit 0 verified | 2 render failed | 3 ifup failed or no DHCP
lease within 30 s | 4 static gateway did not answer
hostname set the kernel hostname and re-render (no network bounce)
Called by: scripts/build/06-verify.sh, etc/default/syslogd, etc/init.d/S02nebulaos-watchdog, etc/init.d/S04nebulaos-factory-seed, etc/init.d/S04nebulaos-migrate, etc/init.d/S50webcam, ...and 25 more.
nebulaos-osconfig - NebulaOS OS-settings helper (GUI/service readiness mission, 2026-10-09).
Called by the [nebulaos] Moonraker component with argv only; usable over SSH for recovery (e.g.
network reset). See /usr/lib/nebulaos/python/nebulaos_osconfig/cli.py for the grammar.
Called by: scripts/build/06-verify.sh, usr/lib/nebulaos/python/nebulaos_osconfig/image.py, usr/lib/nebulaos/python/nebulaos_osconfig/system.py, tests/nebulaos-reset-slot-tests.sh.
nebulaos-slot-swap - set which boot slot (A/B) starts next (S3, 2026-10-11; either slot since 2026-10-14, when NebulaOS may occupy both). Called only by nebulaos-osconfig (system swap-slot / keep-slot, image install). Writes the shared OTA marker through /etc/ota_marker.sh - the exact bytes and durability the documented SSH switch uses (docs/HOW_TO_SWITCH_STOCK_AND_CUSTOM.md) - and reads it back.
A next boot: slot 1 (kernel p5, rootfs p7), marker "ota:kernel"
B next boot: slot 2 (kernel2 p6, rootfs2 p8), marker "ota:kernel2"
exit 0 written and read back | 2 read-back mismatch | 64 usage
Called by: manifests/dependencies.conf, scripts/build/06-verify.sh, etc/nebulaos-update-supervisor.sh, tests/moonraker-nebulascreen-update-config-tests.sh, tests/nebulascreen-legacy-name-gate-tests.sh, tests/update-supervisor-nebulascreen-tests.sh.
No header comment.
Not the system
sftp-server- relocates SFTP to /opt/printer_data.
Called by: scripts/build/06-verify.sh, usr/lib/nebulaos/python/nebulaos_osconfig/system.py, tests/hardware-install-simulation-tests.py, tools/hardware/nebulaos_device.py, tools/hwcap/gs_services.sh.
G62 (2026-10-16): SFTP opens in the user's folder, /opt/printer_data (config, gcodes, logs), not root's empty, read-only home. dropbear starts this path for its sftp subsystem. root's home itself stays /root: Moonraker and others build paths from it (architecture review 2026-10-16).
Called by: scripts/build/06-verify.sh, etc/init.d/S01persistent-datastore, etc/nebulaos-shadow.sh, usr/bin/passwd, tests/nebulaos-shadow-tests.sh.
Change the root password persistently (decision D7). Run over SSH as root:
nebulaos-passwd (also what `passwd` / `passwd root` run)
The password is asked twice and never echoed. See /etc/nebulaos-shadow.sh.
How it works
- Architecture: slots and boot
- Image install and the trial
- Recovery
- The black box
- Printer configuration
Reference (generated)
Build and install
Project