Repository navigation
Boot Services
Everything NebulaOS starts at boot, in the order it starts.
Source: NebulaOS-firmware at b95b156, scripts/build/overlay/etc/init.d/. 36 services.
These are BusyBox init scripts and they run in numeric order. The number is not decoration: later services depend on earlier ones having finished. Revert safety and the persistent datastore come up before anything that could need undoing; the MCU guard runs before Klipper; the image trial and confirm-good steps run last, after everything that proves the system works has had its chance to fail.
Reading this table top to bottom is the most direct description of how a NebulaOS printer starts.
Established from source: that each service exists, its boot order, and what its own header comment says it does.
Also established: the init verbs each script implements, read from its own
case "$1" rather than assumed from the BusyBox convention.
Not established: runtime timings, failure behaviour under load, or anything observed on a printer. Each purpose below is the script author's description, not an independent analysis of the script's behaviour.
| Order | Service | Purpose |
|---|---|---|
S00 |
S00revert-safety |
Boot-time OTA marker policy (FIRMWARE.md sec 21/22). |
S00 |
S00zram-swap |
NebulaOS mutable-runtime mission, Memory Resilience Gate: primary memory-resilience layer. |
S01 |
S01persistent-datastore |
Real, persistent writable storage for this build, replacing the tmpfs stand-in from FIRMWARE.md sec 24 (S01tmpfs-datastore). |
S01 |
S01restore-clock |
G60 (2026-10-16): the KE has no battery-backed clock. |
S01 |
S01splash |
userspace boot display (GUI/service readiness mission, 2026-10-09): the configured boot logo or a blank screen, from right after /usr/data mounts until GuppyScreen takes the framebuffer. |
S01 |
S01wifi |
OpenKE addition (FIRMWARE.md sec 17/18, sec 60): starts wpa_supplicant on wlan0 against real credentials. |
S02 |
S02nebulaos-boot-timing |
NebulaOS boot-timing collector (pre-qualification mission Phase A9, 2026-07-31 - see docs/NEBULAOS_CAMERA_USB_RT_SOURCE_ANALYSIS.md sec 15/ 18.18 Stage B for why this exists: "use device-local timestamps when |
S02 |
S02nebulaos-early-ssh |
start SSH right after the network (2026-10-15). |
S02 |
S02nebulaos-namespace |
NebulaOS mutable-runtime mission, Phase 3: idempotent creation of the /usr/data/nebulaos namespace (mutable apps, envs, printer state, updates, backups, maintenance) - the single authoritative NebulaOS data root. |
S02 |
S02nebulaos-watchdog |
nebulaos-watchdog (G117(b), Run 21): feeds the SoC hardware watchdog (ingenic_wdt, /dev/watchdog) so a frozen printer resets itself instead of hanging until a power cycle (the 2026-10-26 11:16 freeze: ssh, network and |
S03 |
S03nebulaos-blackbox |
nebulaos-blackbox (Run 24, 2026-11-06): at boot, why the previous boot ended (pstore + SoC reset status -> /run/nebulaos/last-reset, logs/ reset-history.log, logs/crash/ after a freeze or crash); then the |
S03 |
S03nebulaos-diskswap |
NebulaOS mutable-runtime mission, Memory Resilience Gate: fallback (lower-priority) disk-backed swap, layered under S00zram-swap. |
S04 |
S04nebulaos-factory-seed |
NebulaOS mutable-runtime mission, Phase 4: idempotent, offline-only first-boot seeding of the mutable Klipper/extensions/Moonraker/Mainsail checkouts |
S04 |
S04nebulaos-migrate |
NebulaOS Clean-Update + Virgin Baseline mission, Phase 3 (2026-08-08): versioned migration for the persistent /usr/data application tree. |
S05 |
S05nebulaos-activate |
NebulaOS mutable-runtime mission, Phase 5: the one authoritative activation manager. |
S40 |
S40nebulaos-ntpsync |
NebulaOS mutable-runtime mission, Phase 7 live-HTTPS qualification: real gap found live - this image never ran any NTP client at all, so the clock stayed permanently stuck at its post-reset default (confirmed: |
S45 |
S45nebulaos-cleanup |
NebulaOS mutable-runtime mission, Phase 9: boots the namespace-restricted retention manager (/etc/nebulaos-retention.sh) in the background, same convention as SimpleAF's own real S45cleanup (pellcorp/creality) - |
S46 |
S46nebulaos-dropbear-keys |
NebulaOS production optimization mission, Phase 5 (2026-07-30): persist Dropbear's SSH host keys across ordinary reboots. |
S49 |
S49nebulaos-memguard |
nebulaos-memguard: memory-pressure supervisor (MEM-T12). |
S50 |
S50nebulaos-mcu-guard |
MCU identity verification before Klipper |
S50 |
S50webcam |
OpenKE addition: starts pellcorp/k1-ustreamer against the real UVC webcam, mirroring Creality's own S50webcam pattern (see FIRMWARE.md sec 6/11) but using an open replacement instead of their closed cam_app + proprietary |
S51 |
S51nebulaos-camera-idle-controller |
NebulaOS camera idle-pause qualification controller (C2, pre- qualification mission Phase A7, 2026-07-31). |
S54 |
S54nebulaos-host-mcu |
starts klipper_mcu, Klipper's own MACH_LINUX build target compiled as a native MIPS Linux program. |
S55 |
S55klipper |
OpenKE addition (FIRMWARE.md sec 13): starts Klipper's host-side "klippy" process. |
S56 |
S56moonraker |
OpenKE addition (FIRMWARE.md sec 13): starts Moonraker. |
S57 |
S57nebulaos-camera-seed |
NebulaOS Moonraker update/camera implementation mission (2026-07-27, see docs/NEBULAOS_MOONRAKER_UPDATE_AND_CAMERA_ANALYSIS.md). |
S57 |
S57nebulaos-mainsail-macros-seed |
NebulaOS Phase 2 macro-cleanup/Mainsail-grouping mission (2026-09-04). |
S57 |
S57nebulaos-timelapse-seed |
Timelapse defaults, set once (2026-10-31): runs /usr/libexec/nebulaos-seed-timelapse in the background, same never-block-boot convention as S57nebulaos-camera-seed - the script waits (bounded) for |
S58 |
S58nebulascreen |
NebulaScreen (based on GuppyScreen; renamed in Run 15): the touchscreen app. |
S59 |
S59nebulaos-update-supervisor |
NebulaOS mutable-runtime mission, Phase 8: launches the update-rollback supervisor (/etc/nebulaos-update-supervisor.sh) as a persistent background poller. |
S95 |
S95mcu-boot-recovery |
Bounded, one-shot recovery for a real, recurring condition found this mission: after a host reboot, Klipper's very first MCU connection attempt sometimes finds the real toolhead MCU already latched in its own firmware |
S97 |
S97nebulaos-display-qualified-apply |
Post-boot, health-gated apply of the live-qualified display configuration (/usr/data/nebulaos/display-qualified.conf - see /etc/nebulaos-display-qualified.sh for the format, validator, and apply |
S98 |
S98nebulaos-display-sleep-wake-controller |
NebulaOS display sleep/wake touch watcher - persistent background daemon. |
S99 |
S99confirm-good |
Boot health check (FIRMWARE.md sec 21/22). |
S99 |
S99nebulaos-housekeeping |
G60 (2026-10-16): one small background loop for periodic NebulaOS chores, instead of one process each: - save the clock (S01restore-clock restores it at the next boot) |
S99 |
S99nebulaos-image-trial |
the one-time automatic fallback after an on-printer image install (human decision 2026-10-14). |
Each section below is the script's own header, kept whole rather than cut to a
first sentence - these headers carry the reasoning behind choices that look
arbitrary from outside, which is the part a developer needs. The verbs are the
arms of the script's own case "$1", so a service that does not implement
restart is visibly missing it rather than assumed to have it.
Verbs: start.
Boot-time OTA marker policy (FIRMWARE.md sec 21/22). Runs before every other init script, even syslogd/klogd.
HISTORY: this script used to unconditionally write "ota:kernel" (stock slot) on every boot, so that any failure before S99confirm-good would cause the next reboot to land on stock automatically. That automatic stock fallback was REMOVED in Phase 1.8B because booting the stock Creality slot auto-flashes the MCU with old Creality firmware, destroying the qualified native GD32F303 MCU build. The risk of silently bricking the MCU outweighs the convenience of automatic rollback.
CURRENT POLICY: this script is a deliberate NO-OP. The OTA marker stays on whatever value it already has (normally "ota:kernel2", i.e. NebulaOS). If Klipper fails to start, the device stays on NebulaOS and requires MANUAL recovery via SSH or USB (see docs/DEVELOPER_RECOVERY.md). This is safer than an automatic stock boot that destroys the MCU firmware.
The ota_marker.sh library is still sourced so that write_ota_marker remains available in this environment for manual recovery commands.
Verbs: start.
NebulaOS mutable-runtime mission, Memory Resilience Gate: primary memory-resilience layer. Real
problem found live on the actual device (docs/NEBULAOS_MEMORY_RESILIENCE.md): only 208MB RAM,
no swap at all (CONFIG_SWAP was missing from the kernel, confirmed via a live "swapon: Function
not implemented" and dmesg OOM-killer events on git clone of an 11.5MB bundle needing only
~37-56MB total-vm). This script activates zram (compressed RAM swap) as early as possible - no
dependency on /usr/data being mounted at all - so it is active well before S04nebulaos-factory-
seed's own memory-heavy git/copy operations run. Runs immediately after S00revert-safety (name
sorts "S00z..." after "S00r..." but still before any "S01*" script - deliberate).
zram chosen over zswap per this mission's own explicit direction: it exposes a plain, ordinary swap device plus simple sysfs stats (comp_algorithm, disksize, mm_stat) that are easy to inspect and qualify on this BusyBox/Buildroot platform - zswap has no equivalent operator visibility. zram writeback is deliberately not implemented in this pass.
Verbs: start, stop.
Real, persistent writable storage for this build, replacing the tmpfs stand-in from FIRMWARE.md sec 24 (S01tmpfs-datastore). That script was a deliberate, temporary safety choice for an untested custom image: /usr/data (mmcblk0p10) is a real ext4 partition physically shared with stock (not duplicated per A/B slot), so it mounted plain tmpfs over it instead, at the cost of losing all state - WiFi credentials, printer.cfg SAVE_CONFIG, GuppyScreen touch calibration - on every single reboot.
Now that the kernel side is a tagged, proven production baseline (functional-production- baseline-2026-07-23) with working A/B fallback, this mounts the real partition - but everything this build writes lives under the single authoritative NebulaOS namespace, /usr/data/nebulaos, never touching whatever else already exists on that shared partition from stock. First-boot- only seeding (checked via existence, not reseeded every boot like the tmpfs version had to be) means real user state - WiFi creds, calibration, SAVE_CONFIG - survives reboots from here on.
NebulaOS mutable-runtime closure mission (2026-07-27): DATA_ROOT used to be /usr/data/openke, a separate legacy path this project's own S02 namespace script had to migrate data OUT of. That path has been fully retired - there is no deployed OpenKE user base, so no migration compatibility is needed for any future device. This script now writes directly into /usr/data/nebulaos, the same authoritative root S02nebulaos-namespace/ S05nebulaos-activate use. PDATA in particular intentionally uses the exact same path S05 later validates and (re-)binds - this makes S01's own bind mount here a genuine, always-available fallback: if S05's own validation ever fails (namespace missing/invalid), /opt/printer_data still ends up bound to this same real, writable directory rather than falling through to the read-only squashfs, where klippy.log/moonraker.log writes would fail outright. S05's bind_if_not_already() already no-ops cleanly when its target is already mounted, so having both scripts bind the identical source directory is deliberate, not a redundant double-mount.
Runs after S00revert-safety, before anything that needs to read/write these paths - webcam/klipper/moonraker/guppyscreen (S50+) all come later, and before S02nebulaos-namespace (this script's own mkdir -p calls below only ever create the specific subdirectories it needs; S02's own layout creation is a superset and is unaffected by running second).
/usr/data itself must exist as a real (if empty) directory baked into the image - scripts/build/overlay/usr/data/.keep is what guarantees that. The root filesystem is squashfs (read-only), so mkdir -p /usr/data below can only ever succeed if that directory already exists on disk to mount over; a genuinely brand-new top-level directory can never be created here at runtime. Found for real: an earlier overlay cleanup pass removed the only file that used to live under usr/data/ in the overlay source (moved to its correct /opt/printer_data location), which silently stopped baking in the usr/data directory itself as a side effect - this whole script then failed every mkdir/mount/cp call in sequence, and GuppyScreen never even started.
Verbs: start, stop, save.
G60 (2026-10-16): the KE has no battery-backed clock. Until NTP answers, every boot starts at 2020-03-01, so files and log lines written early carry a 2020 date (seen in Mainsail). Like fake-hwclock: restore the last saved time as soon as the data partition is mounted (after S01persistent-datastore, before syslogd), save it every 30 minutes (S99nebulaos-housekeeping) and at shutdown. NTP still sets the exact time later; the saved time is never set backwards over a newer one.
Verbs: start.
S01splash - userspace boot display (GUI/service readiness mission, 2026-10-09): the configured boot logo or a blank screen, from right after /usr/data mounts until GuppyScreen takes the framebuffer.
Order: S01persistent-datastore (mounts /usr/data, where the rendered image and the mode live) < S01seedrng < S01splash < S01syslogd < S01wifi - alphabetical within S01, the same convention S01wifi documents.
Never blocks or fails the boot: bounded work (one dd of ~0.5 MB), always exit 0, and on any doubt nothing is drawn. Never touches the backlight. All logic: /usr/libexec/nebulaos/nebulaos- boot-display.
Verbs: start, stop, restart.
OpenKE addition (FIRMWARE.md sec 17/18, sec 60): starts wpa_supplicant on wlan0 against real credentials.
NebulaOS auto-updates-camera-complete mission (2026-07-28): renumbered from S39 to S01 (real bug found live on a fresh-namespace boot). At S39 this ran AFTER S04nebulaos-factory-seed - a step that writes real, previously-uncommitted-scale amounts of data (Klipper's real git history, two Python venvs) and can legitimately take several minutes on this hardware. With WiFi gated behind that, a device had zero network/ SSH reachability for the entire factory-seed window - the only way in was GuppyScreen's touchscreen, and its own WiFi panel needs wpa_supplicant already running to configure anything, a real chicken- and-egg lockout. Two real hard-reboots happened live specifically because of this - an operator with no way to check progress assumed the device was stuck and power-cycled it mid-seed, which is what actually left Klipper/Moonraker's app directories incompletely seeded (not a WiFi defect in itself - see S04nebulaos-factory-seed's own history for the real fix to that half of the incident).
S01 is the earliest safe position: this script's only real dependency is S01persistent- datastore's mount of /usr/data (needed before seed_default_conf() below can create $CONF) - it needs nothing from S02nebulaos-namespace, S03nebulaos-diskswap, or S04's factory seed at all. Same S-number as S01persistent-datastore is intentional and safe (this project's own precedent: S00revert-safety/S00zram-swap already share a number) - conflicts resolve alphabetically ("persistent- datastore" < "wifi"), so the mount always completes first.
This script alone brings wlan0 up (G103 P1: in the background, ifup wlan0 after associating).
/etc/network/interfaces has no auto wlan0, so Buildroot's S40network (ifup -a) never races
it.
ctrl_interface=/var/run/wpa_supplicant in the config already matches what GuppyScreen's own guppyconfig.json expects for its WiFi panel (confirmed directly from its own config key and wpa_event.cpp's directory-scan logic, not guessed).
Lives under /usr/data/nebulaos/ (S01persistent-datastore's DATA_ROOT) rather than /usr/data/wpa_supplicant.conf directly - real, persistent storage now (mmcblk0p10), kept in this build's own namespace rather than the shared partition's top level, same as everything else this build owns. NebulaOS mutable-runtime closure mission (2026-07-27): this previously lived under /usr/data/openke/, a separate legacy path now fully retired - relocated into the single authoritative NebulaOS namespace instead.
Phase 1.5 persistent-namespace mission (2026-08): the canonical path moved one level deeper, into /usr/data/nebulaos/network/, for the same reason printer_data/database/certs/etc already have their own subdirectories rather than sitting loose at the namespace root. Because Wi-Fi is also this device's primary SSH access path, this is not a simple rename - see /etc/nebulaos- wifi-migrate.sh for the full safe migration algorithm (never destroys a working credential file before a verified replacement exists) and tests/nebulaos-wifi-migrate-tests.sh for its test matrix. The OLD path (/usr/data/nebulaos/wpa_supplicant.conf) is kept as a symlink into the new location - both paths are inside /usr/data/nebulaos, so this is a compatibility convenience, not a namespace-ownership exception.
Pre-qualification mission Phase A3 (2026-07-31): also stabilizes wlan0's MAC address before wpa_supplicant/DHCP ever run - see /etc/nebulaos-stable-mac.sh's own header for the real bug this fixes (the shipped Wi-Fi firmware's placeholder NVRAM MAC fields make brcmfmac generate a fresh random MAC every boot).
GUI workstream sec 60: the original version of this script skipped starting wpa_supplicant entirely when $CONF didn't exist yet - correct for a placeholder-safety default on an untested image, but backwards for a device meant to be configured through GuppyScreen's own WiFi panel: the daemon must be running (with no saved network) before GuppyScreen can scan, and SAVE_CONFIG (which GuppyScreen sends after ADD_NETWORK/ SET_NETWORK/ENABLE_NETWORK - confirmed from wifi_panel.cpp) silently does nothing unless update_config=1 is already in the config file. Seeds a minimal, credential-free default config on first boot only (never overwrites a real saved config) - structurally modeled on stock's own real, live /usr/data/wpa_supplicant.conf (ctrl_interface + update_config confirmed identical directives; stock's real SSID/PSK/country entries are this device owner's private data and are deliberately not reproduced here - a fresh device ships with zero saved networks, exactly like stock does out of the box).
Phase 1.5 persistent-namespace mission (2026-08): CONF is no longer a fixed literal - it is whatever /etc/nebulaos-wifi-migrate.sh's nebulaos_wifi_migrate() decides is the right, safe, already-verified config path for this boot (normally the new canonical /usr/data/nebulaos/network/wpa_supplicant.conf; the old /usr/data/nebulaos/wpa_supplicant.conf only on a migration failure, so this boot still has working Wi-Fi/SSH rather than none). 0600 mode and the credential-free virgin skeleton are still exactly what they were before - that logic now lives in the migration script so the same code path is exercised by both a real boot and the offline test suite.
Verbs: start, stop, __run.
NebulaOS boot-timing collector (pre-qualification mission Phase A9, 2026-07-31 - see docs/NEBULAOS_CAMERA_USB_RT_SOURCE_ANALYSIS.md sec 15/ 18.18 Stage B for why this exists: "use device-local timestamps when possible... do not rely solely on remote host scans, which previously produced false positives during DHCP churn" - a real, already-learned lesson from earlier boot-timing work in this project).
Backgrounded, bounded, one-shot: records /proc/uptime (a monotonic, kernel-relative clock, immune to any NTP-not-yet-synced concern) at each of several boot milestones as they're actually observed, via bounded polling - never blocks boot itself. Writes to a tmpfs log (fresh every boot by construction, never grows unbounded across reboots) that scripts/qa/production- benchmark.sh reads when present.
Numbered S02 - after S01persistent-datastore/S01wifi (so wlan0/DHCP bring-up is already in flight to actually observe) but as early as reasonably possible otherwise, so the recorded deltas are meaningful.
Verbs: start.
S02nebulaos-early-ssh - start SSH right after the network (2026-10-15).
On 2026-10-14 ke-dev stopped booting somewhere between Wi-Fi (S01wifi) and SSH (S50dropbear): it answered ping, refused SSH, and the screen stayed on the logo. Everything that would have explained it was in /run and was lost with the power cycle, and getting back needed USB recovery. With SSH up this early, a later step that hangs still leaves the printer reachable, and the Hardware Agent can read the boot log (/run/nebulaos-boot-scripts.log) and the data partition's state.
Same keys and same daemon as the normal start: S46nebulaos-dropbear-keys prepares the host keys and /etc/default/dropbear, S50dropbear starts it. Both run again at their usual place; S50dropbear then finds dropbear running (start-stop-daemon refuses a second instance) and changes nothing.
Verbs: start.
NebulaOS mutable-runtime mission, Phase 3: idempotent creation of the /usr/data/nebulaos namespace (mutable apps, envs, printer state, updates, backups, maintenance) - the single authoritative NebulaOS data root.
IMPORTANT - this script does NOT cut any running service over to the new namespace by itself. S01persistent-datastore's own bind mount of $NEBULAOS_ROOT/printer_data -> /opt/printer_data (a genuine fallback, not just an early seed) means printer_data is already live and writable before this script even runs; activating the OTHER apps (klipper/moonraker/ mainsail) is Phase 5's activation manager - this script only ever creates the structure, so it is safe to run on every boot without affecting anything currently in production.
NebulaOS mutable-runtime closure mission (2026-07-27): this script previously also migrated data out of a separate legacy path, /usr/data/openke/printer_data, left over from before this namespace existed. That path has been fully retired (no deployed OpenKE user base to migrate from), and the one-time migration this script's own migrate_openke_data() performed has already completed and been verified on every device that needed it - the function has been removed rather than kept as permanent, unused migration-compatibility code for devices that will never need it.
Must run after S01persistent-datastore (needs /usr/data already mounted, and DATA_ROOT/printer_data already seeded) and before anything that might depend on the namespace existing later (nothing does yet - reserved numbering for that future dependency).
Verbs: start, stop, restart.
nebulaos-watchdog (G117(b), Run 21): feeds the SoC hardware watchdog (ingenic_wdt,
/dev/watchdog) so a frozen printer resets itself instead of hanging until a power cycle (the
2026-10-26 11:16 freeze: ssh, network and syslogd dead). BusyBox watchdog -F -T 60 -t 10:
hardware timeout 60 s, a feed every 10 s.
Scheduling: SCHED_OTHER at nice -20, deliberately NOT a real-time priority. The feeder must starve exactly when ordinary userspace starves (a runaway RT task - klipper_mcu is FIFO 49, the IRQ threads 25-60 - or a kernel stall): then the reset fires. A FIFO 99 feeder would keep feeding through that freeze. It sleeps almost all the time, so load 4.4 on 2 cores is far from missing a 10 s feed against 60 s.
OOM: -1000 (MEM-T07 policy, memguard table). An OOM kill is SIGKILL - no magic close - and would turn an ordinary OOM event into a reset ~60 s later.
Stop sends SIGTERM: BusyBox watchdog writes the magic 'V' and closes, the driver stops the
counter, so a clean reboot/poweroff (rcK) never fires it. halt -f (no rcK) leaves it armed:
the printer resets after ~60 s. During an unconfirmed image trial every start is counted HERE,
before arming (image trial-count, once per boot): a freeze anywhere in the boot uses up a
start; after 3 unconfirmed starts this boot is not armed and S99 goes back to the previous slot
(G122, docs/A_B_SLOT_MODEL.md).
ON by default (Run 22, 2026-10-28) after watchdog-arm and watchdog-fire passed on ke-dev (2026-10-28): it arms unless $NEBULAOS_SYSTEM_DIR/watchdog.disable exists (the opt-out). The kernel fix that makes arming safe (start() programs the timeout and clears the counter before enabling) is NebulaOS-kernel f28a670 (+ 38c467b marker). A new image that freezes early is bounded: each start is counted below before arming, and the 4th unconfirmed start is not armed (S99 then goes back).
The daemon keeps no fd or cwd on /usr/data (the datafs repair kills the holders of /usr/data): the switch file is read once, the daemon starts from /.
Verbs: start, stop, restart.
nebulaos-blackbox (Run 24, 2026-11-06): at boot, why the previous boot ended (pstore + SoC reset status -> /run/nebulaos/last-reset, logs/ reset-history.log, logs/crash/ after a freeze or crash); then the heartbeat into /dev/pmsg0 for the next boot to read. At shutdown (rcK) a "shutdown clean" line, so the next boot knows the restart was orderly. See /usr/libexec/nebulaos-blackbox.
S03: after S02nebulaos-watchdog has counted this start and armed the watchdog (a hang in collect is then reset like any other), and before Klipper and the rest have started.
Verbs: start, enable, disable, status.
NebulaOS mutable-runtime mission, Memory Resilience Gate: fallback (lower-priority) disk-backed swap, layered under S00zram-swap. zram alone may not be enough for larger/longer memory-heavy operations (Moonraker environment installs, Phase 7); a disk swap file gives a second, larger safety margin before the OOM-killer would otherwise act.
Deliberately NOT the stock swap file at /usr/data/swap - this mission's own direction is not to depend on a stock-owned resource. This is a new, NebulaOS-owned file, sized and managed independently, excluded from retention cleanup (docs/NEBULAOS_RETENTION_POLICY.md - the retention manager's own allowlist never touches system/, so this file is already out of its reach by construction, not by a special-case exception).
Must run after S02nebulaos-namespace (needs /usr/data/nebulaos to exist) and before S04nebulaos-factory-seed (the memory-heavy step this exists to protect).
BOOT-08 (2026-10-05): the swapfile is a MAINTENANCE-ONLY tier. A normal
boot prepares (validates, or creates once) the file but leaves it OFF:
zram is the only swap in normal operation, and memguard (MEM-T13,
MG_DISKSWAP_POLICY=maintenance) already turns this tier on while a
maintenance lock is held and off again afterwards. Measured on ke-dev
before this change: swapon at ~12 s every boot, then memguard swapoff ~30 s
later - pure boot-time eMMC churn. This script still turns it on at boot
when the boot itself is about to do maintenance-class work that runs
before memguard starts (S49):
- S04nebulaos-factory-seed will seed: an app checkout or venv is missing
- S04nebulaos-migrate will migrate: no recorded app generation, or it
differs from this image's seed manifest
- an explicit one-shot request: $NEBULAOS_ROOT/maintenance/diskswap-next-boot
(consumed before use)
Explicit control at any time: `S03nebulaos-diskswap enable|disable|status`.
memguard's defensive swapoff outside maintenance is unchanged.
Verbs: start.
NebulaOS mutable-runtime mission, Phase 4: idempotent, offline-only first-boot seeding of the mutable Klipper/extensions/Moonraker/Mainsail checkouts under /usr/data/nebulaos/apps/*, from the immutable real-history git archives and static-release copy baked into the read-only squashfs at /opt/nebulaos-seeds. Never touches GitHub/PyPI/DNS - this must work with no network at all, so a wiped or first-time namespace can always recover.
Must run after S02nebulaos-namespace (needs the directory layout to already exist). Like S02, this script does NOT cut any running service over to the seeded copies - Phase 5's activation manager does that. This is purely "make sure a valid, verified mutable copy exists on disk."
NebulaOS auto-updates-camera-complete mission (2026-07-28, see
docs/NEBULAOS_MOONRAKER_UPDATE_AND_CAMERA_ANALYSIS.md): the seeded Klipper/Moonraker checkouts
are now REAL git repositories extracted directly from a tar archive of the actual vendor
checkout's .git directory + working tree (04-cross-compile-app-stack.sh's make_seed_archive)
- no bundle, no
git clone, no synthetic commit. This replaces the prior "flattened single- commit snapshot" approach, whose synthetic commit shared no ancestry with the real coreflake1/NebulaOS-klipper or Arksine/moonraker history on GitHub and made Moonraker'sgit merge-base --is-ancestor HEAD origin/<branch>check permanently fail (diverged=true -> is_valid=false), blocking every real update on every freshly-seeded device. The archived HEAD commit is now genuinely reachable from the real remote's branch tip, so the first real/machine/update/status?refresh=truefetch resolves cleanly with no special-case reset required - ordinarygit fetch/pull semantics apply from the very first boot.
Phase 1 no-fork migration (2026-08-17): a THIRD git component is seeded here, apps/nebulaos- klipper-extensions, through the same seed_git_app() with no new machinery - it is deliberately an ordinary image-owned component, not a special case. Klipper's own origin also changes here, from the retired coreflake1/NebulaOS-klipper fork to official Klipper3d/klipper, and that origin is genuinely verified against the seeded checkout below rather than assumed. Klipper and the extensions are treated as one pair for generation-recording purposes: a half-seeded pair records no generation at all, so the next boot retries.
Overridable via env for tests/factory-seed-git-tests.sh (same fixture-path convention as scripts/flash-spare-slot.sh's DEV_PREFIX) - real boot never sets these, so ":=" always resolves to the real paths.
Verbs: start.
NebulaOS Clean-Update + Virgin Baseline mission, Phase 3 (2026-08-08): versioned migration for the persistent /usr/data application tree.
S04nebulaos-factory-seed (runs immediately before this, same S04 slot - see that script's own header) only ever asks "does $APPS/klipper/.git exist at all?" - once seeded, it never checks again, on any later boot, with any later image. That is exactly correct for what IT is for (a fresh/wiped namespace's very first boot must not depend on network at all), but it leaves a real gap this script closes: a device that was ALREADY provisioned, then later reflashed with an image whose pinned Klipper/Moonraker/GuppyScreen commits moved forward, never gets those new commits into its persistent application tree - the squashfs's own factory-seed archive is correct, but nothing ever re-seeds an already-populated $APPS from it. (This is the exact class of gap documented live in docs/NEBULAOS_CANONICAL_DEPLOYMENT_QUALIFICATION.md's "persistent- data Klipper content was not touched by this reflash" finding.)
File-classification model this script relies on (see docs/NEBULAOS_PERSISTENT_LIFECYCLE.md for the full writeup): IMAGE OWNED - $APPS/klipper, $APPS/nebulaos-klipper-extensions, $APPS/moonraker (git checkouts; entirely replaced by this script when the image expects a newer version - there is nothing user-authored inside them)
Phase 1 no-fork migration (2026-08-17): apps/nebulaos-klipper-extensions joins that class as an ordinary third component. Two consequences worth stating explicitly rather than leaving to be rediscovered:
1. Klipper and its extensions migrate as ONE PAIR. If either fails, the
generation is not recorded, so the whole migration retries next boot
- the existing per-component-success rule, extended rather than
replaced. A device must never run new Klipper against old extensions
(that is where the c_helper.so staleness and the
register_response -> register_serial_response API rename both land)
or the reverse.
- Replacing $APPS/klipper wholesale DESTROYS the composed symlinks that activate the extension modules - they live inside the tree this script renames away. That is fine and by design, but it makes the S04 -> S05 ordering load-bearing in a new way: S05nebulaos-activate rebuilds the composition in the same boot, before Klipper starts. Covered explicitly by tests/app-migration- tests.sh.
A firmware update therefore DOES override a user's separately-updated
Klipper or extensions - deliberately, with a full backup under
$SYSTEM/migration-backups/, and only when the image's migration_version
differs. That is the correct behaviour for an appliance and matches how
Klipper and Moonraker have always been treated here. It is documented in
docs/NEBULAOS_UPDATE_OWNERSHIP.md rather than hidden, because "I updated
Klipper in Mainsail and the firmware update reverted it" reads as a bug
to anyone who has not been told.
USER OWNED - $NEBULAOS_ROOT/printer_data/config (printer.cfg,
macros, moonraker.conf) - a SEPARATE directory tree
from $APPS. Safety here comes from directory separation,
not per-file logic.
Phase 1 no-fork migration added a NebulaOS-managed
island inside this tree (printer_data/config/nebulaos/)
to hold movable Klipper-stack update pins. Phase 1.5
RETIRES that island: the pins now live at the
image-owned, read-only /etc/nebulaos/moonraker/
klipper-pin.conf instead, which is automatically
correct for whichever A/B slot is active - see
migrate_moonraker_pin_include() and
migrate_printer_cfg() below for the two small,
idempotent, one-time rewrites this script still performs
on an existing user-owned moonraker.conf/printer.cfg to
point them at that new location.
GENERATED - $NEBULAOS_ROOT/{envs,database,printer_data/{gcodes,
logs,database}} - regenerated/repopulated naturally by
the application itself; also untouched here. Venv
migration (envs/klipper, envs/moonraker) is explicitly
OUT OF SCOPE for this script - they are derived from
the Buildroot/kernel pin, not the Klipper/Moonraker
source pin, and change far less often; a future mission
can extend this the same way if that ever needs it.
Overridable for tests, same convention as S04/flash-spare-slot.sh.
Verbs: start.
NebulaOS mutable-runtime mission, Phase 5: the one authoritative activation manager. Decides, for each mutable app (Klipper, the Klipper extension set, Moonraker, Mainsail) and for printer_data/shared-gcode, whether the persistent /usr/data/nebulaos copy is valid enough to bind-mount over the immutable /opt/*|/usr/share/mainsail path - or whether to leave the immutable squashfs content in place untouched.
Phase 1 no-fork migration (2026-08-17): this script also composes the Klipper stack. NebulaOS runs official, unmodified Klipper, and the modules this project owns are activated by symlinking them from apps/nebulaos-klipper-extensions into apps/klipper/klippy/extras and listing them in that clone's .git/info/exclude - see /etc/nebulaos-klipper-compose.sh for the mechanism and why it is the only one that works.
This is the correct slot for it and no new ordering is needed: S04nebulaos- migrate replaces the whole apps/klipper tree on a generation bump, which destroys the composed symlinks, and S55klipper must not start before they are back. S04 -> S05 -> S55 already gives exactly that, which makes the ordering load-bearing in a way it was not before. tests/app-migration- tests.sh covers it explicitly rather than leaving it to be assumed.
Composition runs BEFORE the bind mount, deliberately. If it fails - a collision with an upstream file, a dangling link, a stale c_helper.so - the persistent Klipper is simply not activated and the immutable copy stays in place, which is this script's existing and well- proven "doing nothing at all is always safe" default rather than a new failure mode.
SAFETY DEFAULT: doing nothing at all is always safe. Every service in this image was built to run directly against its immutable /opt/* path - that is this system's real default state, not a fallback bolted on afterward. Activation only ever ADDS a bind mount on top when a persistent copy has positively proven itself; it never removes or breaks the immutable content underneath, and any check that fails just means "skip this one app, leave it on immutable" rather than aborting the whole boot.
Must run after S02nebulaos-namespace, S03nebulaos-diskswap, and S04nebulaos-factory-seed (needs the layout, swap resilience, and a seeded copy to all exist), and BEFORE S50webcam/S55klipper/ S56moonraker/S58nebulascreen - no service may start against a path that might still change under it. S01wifi (renumbered from S39, see that script's own header) is a deliberate exception
- it has no dependency on this activation step at all, only on S01persistent-datastore's real storage mount, so it now correctly runs before this script rather than after it.
Verbs: start.
NebulaOS mutable-runtime mission, Phase 7 live-HTTPS qualification: real gap found live - this
image never ran any NTP client at all, so the clock stayed permanently stuck at its post-reset
default (confirmed: date -u read "Sun Mar 1 2020" on a device that had been running for
hours) rather than just briefly wrong during early boot. Every TLS handshake to GitHub failed
outright with "certificate is not yet valid" as a direct, permanent consequence - not the
transient pre-NTP window this project's other RTC-drift handling
(docs/NEBULAOS_RETENTION_POLICY.md sec 2.6) already accounts for.
Runs after S01wifi (needs network up, renumbered from S39 - see that script's own header) -
backgrounded, one-shot (ntpd -n -q, query-and-exit, never a persistent daemon), so it never
blocks the rest of boot. Best-effort: no network, DNS failure, or NTP server unreachable all
fail silently rather than affecting boot.
Verbs: start.
NebulaOS mutable-runtime mission, Phase 9: boots the namespace-restricted retention manager (/etc/nebulaos-retention.sh) in the background, same convention as SimpleAF's own real S45cleanup (pellcorp/creality) - backgrounded so it never blocks/delays the rest of boot.
Verbs: start.
NebulaOS production optimization mission, Phase 5 (2026-07-30): persist Dropbear's SSH host keys across ordinary reboots. Buildroot's stock S50dropbear always passes -R (create hostkeys as required) and, with no -r override, dropbear falls back to its compiled-in defaults under /etc/dropbear - which lives on this project's read-only squashfs, so a fresh host key was silently generated on every single boot until now (the exact "REMOTE HOST IDENTIFICATION HAS CHANGED" warning seen after every reboot in this project's own live-testing sessions - see reference_device_access notes). Fixed here by pointing dropbear's -r host-key paths at $NEBULAOS_ROOT/system/dropbear (persistent ext4, bind-mounted well before this script runs) via /etc/default/dropbear, which S50dropbear already sources if present - no change to S50dropbear itself needed.
Must run after S02nebulaos-namespace (needs $NEBULAOS_ROOT/system to exist) and before
S50dropbear. /etc/default/dropbear is baked into the overlay as a symlink to
/var/run/dropbear.conf (tmpfs, writable, fresh every boot) - this script is what actually
populates that file; if it never runs (or fails before writing), /etc/default/dropbear's target
simply won't exist yet, test -r in S50dropbear fails silently, and dropbear falls back to
exactly its original ephemeral-per-boot behavior - a safe, already-proven fallback, not a new
failure mode.
Verbs: start, stop, restart.
nebulaos-memguard: memory-pressure supervisor (MEM-T12). Starts after S45nebulaos-cleanup and before S50webcam, so its markers are honoured from the first ustreamer start. Runs at oom_score_adj -900: the shedder must outlive what it sheds. Nothing depends on it for safety - absent markers mean "normal".
Verbs: start, stop, restart.
S50nebulaos-mcu-guard - MCU identity verification before Klipper
Phase 1.8B Workstream B: READ-ONLY identity check service. Runs before S55klipper (same S50 prefix as S50webcam; alphabetical sort guarantees this script runs first) and queries the GD32F303 MCU's bootloader identity via creality_flash.py's serial-magic protocol. Logs the finding, writes a machine-readable state file to /run/ (tmpfs), and sets an exit code that downstream services can check.
SAFETY CONTRACT:
- This service NEVER flashes the MCU (no erase, no write, no
flash_image, no flash subcommand).
- This service NEVER writes to /dev/mmcblk0p1 or any block device.
- This service NEVER calls write_ota_marker or modifies any
persistent storage.
- The Python helper ALWAYS calls app_start() after querying identity,
returning the MCU to its application firmware.
- The only serial interaction is: enter_bootloader -> get_version ->
app_start (all read-only from the MCU's perspective).
CORRECTED DESIGN (offline pre-build review, superseding the original bootloader-hw-id-only version of this service): mcu_identity_check.py now delegates to mcu_lifecycle.py, which checks APPLICATION identity (which firmware is running - via a pre-Klippy Klipper-protocol identify handshake) separately from HARDWARE identity (which physical MCU - via the bootloader protocol). The two are NOT the same thing: the bootloader hardware ID has been observed identical whether candidate-001 or stock firmware is installed, so the original hw-id-only check could never actually prove candidate-001 was the running application. See mcu_lifecycle.py.
STATE MACHINE (mcu_lifecycle.py / mcu_restore.py):
SUPPORTED_HW_NATIVE_APP -> PASS, start Klipper, no flash
SUPPORTED_HW_KNOWN_STOCK_APP -> restore authorized (mcu_restore.py runs
ONE bounded flash attempt, gated by a
pinned SHA256 + hardware allow-list)
SUPPORTED_HW_UNKNOWN_APP -> WARN, let Klipper try, NEVER auto-flash
UNSUPPORTED_HW -> FAIL, block Klipper, NEVER flash
MCU_UNREACHABLE -> WARN, let Klipper try (its own retry
logic is the second line of defense),
no stock fallback, bounded (one attempt)
See docs/MCU_LIFECYCLE_GUARD.md for the full design document.
Verbs: start, stop, restart, status, __supervise.
OpenKE addition: starts pellcorp/k1-ustreamer against the real UVC webcam, mirroring Creality's own S50webcam pattern (see FIRMWARE.md sec 6/11) but using an open replacement instead of their closed cam_app + proprietary Helix/Felix encoder. "--encoder=HW" means the webcam's own onboard ISP does the MJPEG compression - this never touches Ingenic's hardware encoder at all, the same approach any generic USB-UVC-webcam Klipper printer uses.
USB/webcam stock-parity mission (2026-07-26): the old version of this script hardcoded /dev/video0 and just skipped if it was absent. Real hardware testing (first time this project ever had an actual USB webcam physically attached) showed that assumption was wrong: this SoC's own rotation/H.264-encode/H.264-decode M2M blocks claim /dev/video0-2 unconditionally at boot (ingenic-rot, helix-venc, felix-vdec), regardless of whether any USB webcam is even connected - the real webcam always enumerates at /dev/video3 or later. Also found: ustreamer itself never actually ran before this mission - a real MIPS ABI mismatch between pellcorp's own k1-camera- build toolchain and this project's Buildroot toolchain (see 04-cross-compile-app-stack.sh's own comment) meant the binary could never load at all, independent of this script.
This version discovers the real UVC capture node dynamically via v4l2-ctl (now actually packaged - see 00-fetch-vendor-sources.sh/ 04-cross-compile-app-stack.sh), and runs as a small supervising loop (bounded 5s poll, not a busy loop) so a webcam that enumerates late, or is unplugged/replugged later, is picked up without a reboot. mdev/hotplug event wiring would be the more "correct" event-driven approach, but this system doesn't currently wire the kernel hotplug sysctl to mdev at all - doing that is a bigger, more invasive change to how this whole image manages devices than this mission's actual scope, so the supervising loop (explicitly listed as an acceptable option) is the smaller, safer choice.
Verbs: start, stop, restart, __run.
NebulaOS camera idle-pause qualification controller (C2, pre- qualification mission Phase A7, 2026-07-31). See /etc/nebulaos-camera- idle-controller.sh's own header for the full design rationale.
Numbered S51 - right after S50webcam (which this depends on being already started) and well before S55klipper/S56moonraker, so this controller is up before either application stack. C2 (idle auto-pause) is the default (S3, 2026-10-05); a marker containing exactly "C0" opts out and makes start a true no-op. It never delays or blocks anything after it.
Deliberately a completely separate process/supervisor from S50webcam's own - a crash or bug in this experimental controller can never affect S50webcam's already-hardware-proven ustreamer crash recovery, since this script only ever calls ustreamer's existing local /pause and /resume HTTP endpoints, never touches the ustreamer process itself.
Verbs: start, stop, restart, reload.
S54nebulaos-host-mcu - starts klipper_mcu, Klipper's own MACH_LINUX build target compiled as a native MIPS Linux program.
Phase 1.9A host-MCU restoration: this is the process that serves [mcu rpi] over /tmp/klipper_host_mcu for the physical ADXL345 accelerometer and BL24C16F EEPROM, both wired directly to the SoC. It has no interaction with the separate GD32F303 stepper-driver MCU that S50nebulaos-mcu-guard manages - different chip, different bus, different socket.
Numbered S54, between S50nebulaos-mcu-guard and S55klipper: NebulaOS prefers this MCU to already be available before Klippy starts, rather than relying on Klippy's own connect_pipe() retry window (90s, 5s intervals - klippy/serialhdl.py) the way stock's own S55-before-S57 ordering does. There is no correctness requirement forcing this order (Klippy tolerates the socket not existing yet at startup either way - see _project/missions/phase1.9-host-mcu- accelerometer-plr-analysis.md section 2), but a deterministic "the MCU is already there" boot is simpler to reason about than one relying on a retry loop succeeding in time, so this script comes first.
Verbs: start, stop, restart.
OpenKE addition (FIRMWARE.md sec 13): starts Klipper's host-side "klippy" process. Must come before S56moonraker, since Moonraker connects to klippy's own API-server Unix socket, not the other way around.
Verbs: start, stop, restart.
OpenKE addition (FIRMWARE.md sec 13): starts Moonraker. Must come after S55klipper - connects to klippy's API-server Unix socket as a client via moonraker.conf's [server] klippy_uds_address, which already correctly points at klippy.sock.
UNIXSOCKET below is a completely different thing: moonraker.py's own -u/--unixsocket flag sets its own "extensions" agent-socket server (components/extensions.py's create_unix_server), unrelated to the klippy connection. This previously pointed at klippy.sock itself, which made Moonraker bind a second listening socket directly on top of Klipper's own socket path - moonraker's own extensions server replaced Klipper's real socket file, so any new connection attempt to that path (including moonraker's own klippy_connection reconnect logic) landed on moonraker's extensions listener instead of real Klipper, producing a continuous flood of "JSON- RPC Request Error -32600, Invalid Request" and "Unknown method received: notify_proc_stat_update" and leaving Moonraker permanently stuck before the Klippy-ready handshake (GuppyScreen/Mainsail stuck on "waiting for printer to initialize" forever, even though Klipper itself was fully connected to the real MCU). Fixed by giving Moonraker's own extensions socket its own separate path.
Verbs: start.
NebulaOS Moonraker update/camera implementation mission (2026-07-27, see docs/NEBULAOS_MOONRAKER_UPDATE_AND_CAMERA_ANALYSIS.md). Runs /usr/libexec/nebulaos-seed-camera in the background, same never-block-boot convention as S45nebulaos-cleanup - the script itself already retries with a bounded backoff while waiting for Moonraker to answer its own webcam API, so backgrounding here is what keeps that wait from ever delaying the rest of boot.
Runs after S56moonraker (Moonraker's own startup) by number, though the script does not actually depend on strict ordering - it polls Moonraker's real HTTP API rather than assuming any particular timing.
Verbs: start.
NebulaOS Phase 2 macro-cleanup/Mainsail-grouping mission (2026-09-04). Runs /usr/libexec/nebulaos-seed-mainsail-macros in the background, same never-block-boot convention as S57nebulaos-camera-seed - the script itself already retries with a bounded backoff while waiting for Moonraker to answer its own database API, so backgrounding here is what keeps that wait from ever delaying the rest of boot.
Shares the S57 number with S57nebulaos-camera-seed on purpose (both need to run after S56moonraker, neither actually depends on strict relative ordering between the two of them since each polls Moonraker's own real API rather than assuming any particular timing) - the "-c" vs "-m" alphabetical tail keeps them both deterministically ordered without needing a free integer slot (S58/S59 are already taken).
Verbs: start.
Timelapse defaults, set once (2026-10-31): runs /usr/libexec/nebulaos-seed-timelapse in the background, same never-block-boot convention as S57nebulaos-camera-seed - the script waits (bounded) for Moonraker's [timelapse] component itself.
Verbs: start, stop, restart.
NebulaScreen (based on GuppyScreen; renamed in Run 15): the touchscreen app. Draws directly to the framebuffer/touch device, independent of the network - the only way to see what a printer does when Wi-Fi is down.
Numbered after Moonraker - NebulaScreen is a real-time client of Moonraker's websocket API, though it degrades to a reconnecting/error state rather than failing outright if Moonraker isn't up yet.
G82: before every start, "the image wins" (a new system image drops a downloaded NebulaScreen release) and a complete downloaded release is bound over the image's own files, item by item (/etc/nebulascreen-lib.sh). With no release downloaded both are no-ops and the image's own NebulaScreen runs.
Verbs: start, stop, restart.
NebulaOS mutable-runtime mission, Phase 8: launches the update-rollback supervisor (/etc/nebulaos-update-supervisor.sh) as a persistent background poller. Must start after S55klipper/S56moonraker/S58nebulascreen - it queries Moonraker's HTTP API and needs the real service topology (and any already-bind-mounted persistent apps from S05) to be up first, and its own restart actions target those same services.
NebulaOS Memory Resilience Gate: this is an update/maintenance-class helper, the most expendable class in the project's own OOM priority table (docs/NEBULAOS_MEMORY_RESILIENCE.md sec 3.3) - 500, same as the factory-seed shell.
Verbs: start, stop.
Bounded, one-shot recovery for a real, recurring condition found this mission: after a host reboot, Klipper's very first MCU connection attempt sometimes finds the real toolhead MCU already latched in its own firmware "shutdown" state (mcu.py: "Can not update MCU 'mcu' config as it is shutdown"), which requires an explicit FIRMWARE_RESTART to clear - stock does not need this for the same real MCU/serial config (confirmed: same printer.cfg [mcu] serial/baud/restart_method, read directly from stock's own rootfs), but stock's own klippy.log never showed this exact error either, even across a plain serial "Timeout on connect" that it recovered from cleanly on its own. The underlying hardware/timing root cause was not conclusively isolated (no dmesg evidence of anything else touching /dev/ttyS1, no differing kernel-side UART config found) - this guard treats the symptom safely rather than assuming a specific electrical explanation.
Why this is safe to automate: the "config as it is shutdown" error is structurally raised by mcu.py during _mcu_identify(), before printer.cfg is ever parsed - confirmed live, the /printer/info response in this exact state has no "status" object at all (no heater targets, no homed_axes - they don't exist yet). There is no G-code queue, no possibility of a nonzero heater target or in-progress motion at this specific point, because Klipper has not yet reached the part of startup where any of that could exist. This is categorically different from a shutdown occurring after the printer was already ready (mid-print, mid-heat, etc.), which this script does not and must not try to handle - that is a genuine fault and is deliberately left alone.
Bounds enforced:
- runs only once per real boot (both by being a plain init.d "start"
action, and belt-and-suspenders via a /run/ marker - /run/ is tmpfs,
so the marker cannot survive a real reboot and force a skip forever)
- matches only the exact, known error string above - any other
state_message (thermal, ADC, config, stepper/endstop faults, or an
unrecognized message) is left completely untouched
- issues at most one FIRMWARE_RESTART call, ever, per boot - if that
single attempt does not clear it, this script does not retry further;
the printer is left in its (safe, pre-ready, non-heating, non-moving)
error state for a human to look at, matching S99confirm-good's own
"don't guess, leave the marker on stock" posture
- never fires once klippy_state has already reached "ready" this boot -
the poll loop below exits immediately the first time it sees "ready",
before ever considering recovery action
Verbs: start, stop, restart.
Post-boot, health-gated apply of the live-qualified display configuration (/usr/data/nebulaos/display-qualified.conf - see /etc/nebulaos-display-qualified.sh for the format, validator, and apply logic this script is a thin init.d wrapper around).
SAFETY DEFAULT, stated once here and never violated below: both kernel drivers this script can touch already boot in an independently-safe state on their own (touch: poll-only, always, regardless of Kconfig - CONFIG_TOUCHSCREEN_NS2009_FINAL_QUALIFICATION's own Kconfig text; backlight: boot-preserve, zero hardware claims, bootloader's own state left untouched - CONFIG_NEBULAOS_BACKLIGHT_FINAL_CONTROLLER's own file header). This script only ever ADDS an explicit, qualified transition on top of that; if ANYTHING here is uncertain, missing, malformed, or fails, the correct action is to do nothing further and let those already-safe kernel defaults stand - never a best-guess partial application. This script running at all, or being entirely absent, or failing outright, must never be able to make the display worse than doing nothing.
INIT.D POSITION (S97) AND WHY: this project's own numbering has Klipper=S55, Moonraker=S56, GuppyScreen=S58, and two existing terminal gates - S95mcu-boot-recovery (bounded one-shot MCU- shutdown recovery) and S99confirm-good (the final OTA-marker "was this boot actually good" gate, see that script's own header). This script deliberately sits BETWEEN those two: after S95 has had its own chance to clear a stuck-MCU condition (which would otherwise make this script's own Klipper-health poll below wait out its full timeout for nothing), and before S99 so that a slow-to-report-healthy boot still gets the same generous, independent retry window S99 itself uses, without artificially forcing this script to depend on S99's own marker-flip decision (S99's job is OTA rollback bookkeeping, a different concern from "is it safe to touch the display now" - this script does its own health polling rather than reading S99's result, since BusyBox's plain numeric init.d ordering (S00-S99, sorted as strings) has no room for a script number after S99 anyway - "S100" would sort BEFORE "S99" as a string, not after).
HEALTH GATES, in the order the mission spec lists them - reusing this
project's own established checks rather than reinventing them:
- Klipper + Moonraker reachable: /etc/nebulaos-healthcheck.sh's own
`stage2` mode (unmodified, invoked exactly as its own "Usage:
nebulaos-healthcheck.sh stage2" header documents, never sourced -
see ndq_stage2_stack() below for why) - the exact same Moonraker
/server/info klippy_state=ready + klippy_connected=true check
S99confirm-good and S95mcu-boot-recovery both already rely on.
- NebulaScreen running: its own S58nebulascreen PIDFILE
(/var/run/nebulascreen.pid), checked the same way S58 itself manages
it (start-stop-daemon's pidfile convention) - `kill -0` against the
recorded PID.
- the display itself: the backlight-final-controller driver's sysfs
actual_brightness exists and a read of it actually succeeds (see
display_healthy() below) - proves the kernel driver this script is
about to issue commands to is really loaded and responding, without
attempting to interpret display CONTENT (out of scope, and not
meaningful from here anyway).
- networking up: a non-loopback interface holding an IPv4 address,
via the real iproute2 `ip` binary this image ships (BR2_PACKAGE_IPROUTE2 -
see halley5-nebulaos-fragment.config's own comment on why BusyBox's
minimal `ip` applet was replaced with the real package). Klipper/
Moonraker's own health check above only ever talks to 127.0.0.1 and
would pass even with networking fully down, so this is a genuinely
separate check, not redundant with the ones above.
Runs at most once per real boot (belt-and-suspenders /run/ marker, same pattern S95mcu-boot- recovery already uses - /run/ is tmpfs, so the marker cannot itself survive a reboot).
Library paths are overridable so the offline test suite (tests/nebulaos-display-qualified- tests.sh) can exercise this entire script, unmodified, against fakes - there is no live init system or running kernel to test against from this environment. Every default below matches this system's real, established paths exactly.
Verbs: start, stop, restart, watch, __run.
NebulaOS display sleep/wake touch watcher - persistent background daemon. See /etc/nebulaos- display-sleep-wake-controller.sh's own header for the full design rationale and the sysfs interfaces it uses.
Numbered S98 - after S97nebulaos-display-qualified-apply's start, and still before S99confirm- good. Since G103 P4 (Run 21) S97 only checks its config and then waits for health and applies in the BACKGROUND, so this usually runs (restore + watcher decision) BEFORE the qualified apply; S97's ndq_apply_deferred_fields() may then invoke this script's start() again once sleep_enabled=1/touch_wake_mode=polling are confirmed applied, and S97 keeps the restored brightness (NDQ_BACKLIGHT_KEEP_ON). Running twice (once via S97's own explicit start request, once again here in the normal S* boot sequence) is always safe: start-stop-daemon's own pidfile check makes a second start() call here a genuine no-op whenever the daemon is already running, never a duplicate process.
Deliberately a completely separate process/supervisor from anything else in this codebase - a bug in this watcher can never affect touch or backlight's own independently-safe kernel boot defaults (it only ever reads sysfs attributes and, at most, clears the backlight's bl_power - see the library for exactly what it touches).
Its own start() is a true no-op whenever touch_wake_mode is not "polling" in the persisted config (the default, pre-qualification state) - matching S51nebulaos-camera-idle-controller's own "no-op unless requested" pattern precisely, never delaying or blocking anything after it either way.
Verbs: start.
Boot health check (FIRMWARE.md sec 21/22). Runs last (S99), well after klipper/moonraker/guppyscreen/nginx have been started by their own S5x scripts. Polls Moonraker's HTTP API for real Klipper readiness rather than assuming "started" means "healthy."
USB/webcam+MCU-recovery mission (2026-07-26): the original version of this check only confirmed that /server/info returned some "result" - true the instant Moonraker's HTTP listener is up, regardless of whether Klipper ever actually connected to the real MCU. A real, found-live gap: this would have happily flipped the marker forward on a boot where the MCU was stuck in its own shutdown state (see S95mcu-boot-recovery), since Moonraker itself was always healthy in that case - only Klipper's own connection to the printer was not. /server/info's own "klippy_state" field is Moonraker's real, live answer to "is Klipper actually ready," confirmed live (returns "ready" only once Klipper has loaded config and connected the MCU) - checking that instead is a strictly more correct version of the exact same intent this script always had.
PHASE 1.8B CHANGE: this script NO LONGER writes the OTA marker on any path (success or failure). The marker stays on "ota:kernel2" (NebulaOS) regardless. The automatic stock fallback was removed because booting the stock Creality slot auto-flashes the MCU with old Creality firmware, destroying the qualified native GD32F303 MCU build. If Klipper fails to reach "ready", the device stays on NebulaOS and requires MANUAL recovery (see docs/DEVELOPER_RECOVERY.md).
The health check is retained for its diagnostic value: the log messages tell you whether Klipper came up healthy, which is critical information for debugging a boot that "worked" but left the printer non-functional.
wget here is BusyBox's applet (no full wget/curl in this rootfs) - confirmed CONFIG_FEATURE_WGET_LONG_OPTIONS + CONFIG_FEATURE_WGET_TIMEOUT are both enabled in this build, and this is plain local HTTP (no TLS involved), so the HTTPS-support gap that bit an earlier fetch in this project doesn't apply here.
Verbs: start, stop.
G60 (2026-10-16): one small background loop for periodic NebulaOS chores,
instead of one process each:
- save the clock (S01restore-clock restores it at the next boot)
- refresh printer_data/logs/nebulaos-state.txt (versions, slots, MCU,
storage) so Mainsail's own "download as zip" of the logs carries it
- G69: Debug logging back to Info after 24 hours
- G66: the retention job (logs, old backups, old uploaded system images),
skipped while printing; S45 still runs it at boot
- G65: a settings snapshot when the settings changed since the newest one
(so a copy always predates the next update)
First pass right away (end of boot), then every 30 minutes.
Verbs: start.
S99nebulaos-image-trial - the one-time automatic fallback after an on-printer image install
(human decision 2026-10-14). When the install left a trial record for this slot, nebulaos- osconfig image trial waits (in the background, the boot is not held up) for Klipper and the
screen to come up: then the new version is confirmed; otherwise the marker goes back to the
previous slot ONCE and the printer reboots. The record is removed before that switch, so it can
never loop. Limit: a kernel that does not start at all never gets here (the bootloader counts
no boots) - that needs USB recovery. G91 (2026-10-22): a switch into a slot that never
confirmed itself writes a trial record too (system swap-slot); the verdict waits while a print
runs (never a switch + reboot under it) and the record stays meanwhile, so a power cut re-runs
the trial; a screen memguard shed for memory counts as up.
Each script takes start, stop and usually restart, the BusyBox init
convention. On a running printer:
/etc/init.d/S58nebulascreen restartThat is also what Settings -> System -> Restart GUI does on the touchscreen, which is the supported way to do it and needs no SSH.
How it works
- Architecture: slots and boot
- Image install and the trial
- Recovery
- The black box
- Printer configuration
Reference (generated)
Build and install
Project