diff --git a/.gitignore b/.gitignore
index 63180d29..d2c7238a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,10 +41,12 @@ tools/__pycache__/
/CLAUDE.md
.claude/skills/
docs/superpowers/
-# docs/commands/ is local-only evidence, EXCEPT the two catalog fixtures that
-# CommandCatalogDriftTest (and therefore CI) requires in every clone.
+# docs/commands/ is local-only evidence, EXCEPT the tracked artifacts CI requires in every clone:
+# the two catalog fixtures (CommandCatalogDriftTest) and the generated, published human command
+# reference (CommandReferenceDocDriftTest renders it from catalog.json).
docs/commands/*
!docs/commands/catalog.json
!docs/commands/printer-matrix.json
+!docs/commands/COMMANDS.md
docs/view_specific_notes/
docs/top-down-audit-roadmap.md
diff --git a/README.md b/README.md
index 96b39674..b33a5985 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,13 @@ The release split produces one APK per ABI (`armeabi-v7a`, `arm64-v8a`). Release
unsigned by the default build — sign them with your own keystore (`apksigner`) before
installing, or use the published, signed APKs from Releases.
+## Printer integration
+
+jiib's registry-backed printer-control surface (Moonraker WebSocket JSON-RPC and Klipper G-code) is
+documented in **[docs/commands/COMMANDS.md](docs/commands/COMMANDS.md)**. The generated reference stays
+in sync with the runtime command registry; its scope deliberately excludes auxiliary direct HTTP
+traffic such as authentication, file/thumbnail access, and webcam streams or snapshots.
+
## Tech stack
Kotlin · Jetpack Compose + classic Views hybrid · OkHttp (WebSocket) + Retrofit (REST) ·
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index dca04085..caf5504c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -117,6 +117,15 @@ android {
// shows through is a still-hardcoded literal — the i18n completeness sweep. Build-time
// capability only (no translations shipped); the project has no resConfigs to trim.
isPseudoLocalesEnabled = true
+
+ // Side-by-side DEV install (2026-07-05): suffix the applicationId so a debug/dev build
+ // installs as `works.mees.jiib.dev` ALONGSIDE the signed live `works.mees.jiib` — the dev
+ // branch can be tested between releases without uninstalling the live app. Separate
+ // applicationId ⇒ separate app data (its own DataStore: profiles/settings start empty).
+ // The debug label is overridden to "jiib dev" via app/src/debug/res (same launcher icon).
+ // No manifest authorities are hardcoded (checked), so no provider-conflict at install.
+ applicationIdSuffix = ".dev"
+ versionNameSuffix = "-dev"
}
}
@@ -151,6 +160,14 @@ android {
// unmocked android.jar methods must return defaults instead of throwing "not mocked".
testOptions {
unitTests.isReturnDefaultValues = true
+ // Forward `jiib.*` system properties (e.g. -Djiib.regenerateDocs=true for
+ // CommandReferenceDocDriftTest) from the Gradle invocation into the forked test-worker JVM;
+ // Gradle does not propagate arbitrary -D props to test workers by default.
+ unitTests.all {
+ it.systemProperties(System.getProperties()
+ .filterKeys { k -> k.toString().startsWith("jiib.") }
+ .mapKeys { (k, _) -> k.toString() })
+ }
}
compileOptions {
diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml
new file mode 100644
index 00000000..080d4987
--- /dev/null
+++ b/app/src/debug/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+ jiib dev
+
diff --git a/app/src/main/java/works/mees/jiib/designsystem/control/OutlinedControl.kt b/app/src/main/java/works/mees/jiib/designsystem/control/OutlinedControl.kt
index a07a8af2..4eab5dbb 100644
--- a/app/src/main/java/works/mees/jiib/designsystem/control/OutlinedControl.kt
+++ b/app/src/main/java/works/mees/jiib/designsystem/control/OutlinedControl.kt
@@ -22,6 +22,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.platform.LocalDensity
@@ -216,7 +217,11 @@ fun OutlinedControl(
) {
MaterialSymbol(
name = symbol,
- modifier = symbolA11y,
+ // Icon+label: the glyph is DECORATIVE — the visible label is the accessible name.
+ // MaterialSymbol renders the ligature as Text, so without clearing semantics TalkBack
+ // would read the raw ligature name (e.g. "output_circle Extrude"). Clear it so only the
+ // label speaks; the icon-only branch above keeps its contentDescription (Codex 2026-07-06).
+ modifier = Modifier.clearAndSetSemantics {},
tint = t.text,
// 0.6U when U is provided; legacy text-tracked 22sp otherwise.
sizeSp = unitDp?.let { (it.value * 0.6f) / LocalDensity.current.fontScale }
diff --git a/app/src/main/java/works/mees/jiib/di/AppContainer.kt b/app/src/main/java/works/mees/jiib/di/AppContainer.kt
index bc37168c..75ffa217 100644
--- a/app/src/main/java/works/mees/jiib/di/AppContainer.kt
+++ b/app/src/main/java/works/mees/jiib/di/AppContainer.kt
@@ -508,8 +508,9 @@ class AppContainer(
/**
* Display app-setting persistence (§R2, 26.5-05) — the SEPARATE display.preferences_pb-backed store
* holding the [DisplayPrefs.keepScreenOn] toggle (default true — the dedicated-display use case) and
- * the app-global [DisplayPrefs.webcamEnabled] toggle (default true — moved per-profile→app-global
- * 2026-06-15; surfaced as [webcamEnabled]/[setWebcamEnabled] and gated into [webcamTileEnabled]).
+ * the app-global [DisplayPrefs.webcamEnabled] toggle (default FALSE since 2026-07-05 — opt-in until
+ * the webcam rotation/stability issues are fixed; moved per-profile→app-global 2026-06-15; surfaced
+ * as [webcamEnabled]/[setWebcamEnabled] and gated into [webcamTileEnabled]).
* Like [babystepPrefs]/[macroPrefs] it is PROCESS-SCOPED + CONNECTION-INDEPENDENT (NOT a field on
* [SpineHandle]): the setting survives reconnects and printer swaps. The Settings UI reads
* [keepScreenOn] and writes through the durable [setKeepScreenOn] intent; AppShell's root effect
@@ -676,6 +677,29 @@ class AppContainer(
writeScope.launch {
val firstProfileId = activeProfileId.filterNotNull().first()
traceStylePrefs.migrateUnscopedTo(firstProfileId)
+
+ // Default-select ALL temperature_sensors on each printer's FIRST visit (Design A, 2026-07-05)
+ // so the monitoring page shows every thermometer out of the box — obviously distinct from the
+ // heaters — and the user configures DOWN via the picker/visibility. Runs after the legacy
+ // migration above so an existing scoped selection is visible (and thus preserved). Per-profile
+ // and idempotent via the TraceStylePrefs sentinel; needs the live session's capabilities to
+ // know the available sensor set. collectLatest re-arms on printer switch (cancels a pending
+ // wait for a sensor-less printer). Process-lifetime writeScope (never composition).
+ //
+ // CORRELATE caps to THIS profile's session (Codex review): `spine`/`capabilities` are a HOT
+ // StateFlow that keeps replaying the PREVIOUS printer's handle right after a profile switch
+ // (the service swaps the spine asynchronously over the network). Reading the global caps could
+ // seed printer B with printer A's sensors and stamp B's sentinel, permanently. Mirror the
+ // name-seed's `sessionConfig` guard: take the spine handle whose sessionConfig matches THIS
+ // profile's config, then read THAT session's sensors — never a stale cross-printer snapshot.
+ activeProfileId.filterNotNull().collectLatest { pid ->
+ val cfg = activeConfig.filterNotNull().first()
+ val sensors = spine.filterNotNull().first { it.sessionConfig == cfg }
+ .capabilities
+ .map { caps -> caps.objects.filter { it.startsWith("temperature_sensor ") }.sorted() }
+ .first { it.isNotEmpty() }
+ traceStylePrefs.applyDefaultSensorSelection(pid, sensors)
+ }
}
// One-time font-scale migration (must NOT block on an active profile). Seeds the app-global
diff --git a/app/src/main/java/works/mees/jiib/state/PrinterState.kt b/app/src/main/java/works/mees/jiib/state/PrinterState.kt
index 1723498d..314bcca8 100644
--- a/app/src/main/java/works/mees/jiib/state/PrinterState.kt
+++ b/app/src/main/java/works/mees/jiib/state/PrinterState.kt
@@ -118,9 +118,30 @@ data class PrinterState(
/** `print_stats.info.total_layer` — NULLABLE (same caveat as [currentLayer]); else metadata layer_count. */
val totalLayer: Int? = null,
- /** Print progress 0.0..1.0 from `virtual_sdcard.progress` / `display_status.progress`. */
+ /**
+ * Displayed print progress 0.0..1.0 — the DERIVED value the progress ring reads. Prefers the
+ * slicer's M73 estimate ([displayProgress]) when present, else file position ([sdcardProgress]),
+ * with a monotonic clamp within a print so it never ticks backward. See the reducer's progress
+ * section (2026-07-06 ring-jank fix). Do NOT set this from a single raw source.
+ */
val progress: Double = 0.0,
+ /**
+ * Last `virtual_sdcard.progress` (file byte position ÷ size); null until reported / after a new-print
+ * reset — a raw progress SOURCE, persisted across partial diffs so a diff carrying only this object
+ * can't flip [progress]. Not read by the UI directly; feeds the [progress] derivation as the fallback
+ * when there's no slicer M73. Nullable so the stale-M73 guard fires only when file position is
+ * actually KNOWN-and-low, not merely unreported.
+ */
+ val sdcardProgress: Double? = null,
+
+ /**
+ * Last `display_status.progress` (the slicer's M73 estimate; null until one arrives) — the raw
+ * progress SOURCE the derivation prefers. Persisted across partial diffs so a file-position-only
+ * diff can't flip [progress] off the slicer estimate. Not read by the UI directly.
+ */
+ val displayProgress: Double? = null,
+
/** `pause_resume.is_paused` — state-confirmation truth for pause/resume controls. */
val pauseResumePaused: Boolean = false,
diff --git a/app/src/main/java/works/mees/jiib/state/PrinterStateReducer.kt b/app/src/main/java/works/mees/jiib/state/PrinterStateReducer.kt
index e58524c7..af059f37 100644
--- a/app/src/main/java/works/mees/jiib/state/PrinterStateReducer.kt
+++ b/app/src/main/java/works/mees/jiib/state/PrinterStateReducer.kt
@@ -73,6 +73,12 @@ fun applyKlippyMethod(current: PrinterState, method: String): PrinterState {
// base state differs). Every accessor below is null-safe: a missing object/field falls through to `current`.
// ---------------------------------------------------------------------------------------------------------
+/** Below this file-position progress a print is "just started" — the window where a stale prior-job M73 can linger. */
+private const val PROGRESS_START_SETTLE = 0.1
+
+/** At print start, an M73 estimate this far ABOVE file position is treated as stale (a fresh print isn't ~99%). */
+private const val PROGRESS_STALE_M73_GAP = 0.5
+
internal fun applyStatus(current: PrinterState, status: JsonObject): PrinterState {
var s = current
@@ -162,9 +168,47 @@ internal fun applyStatus(current: PrinterState, status: JsonObject): PrinterStat
)
}
- // Progress can arrive on either virtual_sdcard or display_status; last writer wins per frame.
- status.objectOrNull("virtual_sdcard")?.doubleOrNullAt("progress")?.let { s = s.copy(progress = it) }
- status.objectOrNull("display_status")?.doubleOrNullAt("progress")?.let { s = s.copy(progress = it) }
+ // Progress (2026-07-06 ring-jank fix). Moonraker reports two disagreeing progress values on two
+ // objects — file position on `virtual_sdcard` (updates every move) and the slicer's M73 estimate on
+ // `display_status` (updates only when M73 fires). The old "last writer wins per frame" let partial
+ // diffs flip the ring between them. Instead: persist each source independently, PREFER the slicer
+ // estimate when present (matches the printer's own LCD) and fall back to file position, then clamp
+ // MONOTONICALLY within a print so an M73 downward revision can't tick the ring backward. The clamp
+ // resets when a new print starts (state entered printing, or the filename changed) so the ring
+ // never shows the previous job's value — the stale sources are cleared before this diff's values land.
+ run {
+ val nowPrinting = s.printState == PrintState.Printing || s.printState == PrintState.Paused
+ val wasPrinting = current.printState == PrintState.Printing || current.printState == PrintState.Paused
+ val newPrint = nowPrinting &&
+ (!wasPrinting || (s.printFilename.isNotBlank() && s.printFilename != current.printFilename))
+ if (newPrint) s = s.copy(sdcardProgress = null, displayProgress = null, progress = 0.0)
+
+ status.objectOrNull("virtual_sdcard")?.doubleOrNullAt("progress")?.let { s = s.copy(sdcardProgress = it) }
+ status.objectOrNull("display_status")?.doubleOrNullAt("progress")?.let { s = s.copy(displayProgress = it) }
+
+ // Prefer the slicer M73 estimate; fall back to file position. GUARD only the print-START window
+ // (Codex review 2026-07-06): a reconnect snapshot or a back-to-back queued print can carry the
+ // PRIOR job's stale M73 before this job's first M73 lands. If file position is KNOWN-and-low
+ // (a fresh print) yet M73 is implausibly far above it, M73 is stale — use file position until it
+ // resyncs. The guard fires only when file position is actually reported (`f != null`), so M73
+ // that legitimately climbs while sdcard is momentarily unreported is still trusted. Mid-print the
+ // two diverge only modestly, so M73 is always preferred there.
+ val d = s.displayProgress
+ val f = s.sdcardProgress
+ val staleStartM73 = d != null && f != null && f < PROGRESS_START_SETTLE && d > f + PROGRESS_STALE_M73_GAP
+ // Once identified as stale, DISCARD the prior-job M73 source instead of merely ignoring it for
+ // this tick. Otherwise it survives until file progress reaches PROGRESS_START_SETTLE, becomes
+ // eligible again, jumps the ring to ~99%, and the monotonic clamp makes that jump irreversible.
+ // A later display_status update writes a fresh candidate which is accepted once it is plausible
+ // relative to the current file position.
+ if (staleStartM73) s = s.copy(displayProgress = null)
+ val raw = (if (!staleStartM73 && d != null) d else (f ?: 0.0)).coerceIn(0.0, 1.0)
+ // Clamp forward only while actively continuing a print; otherwise show the raw value (so a
+ // fresh/reset print starts near 0 and Complete/Standby reflect the real reading). A newly-detected
+ // stale M73 is also allowed to correct downward: clamping against the stale displayed value would
+ // pin the new print near the prior job's progress forever.
+ s = s.copy(progress = if (nowPrinting && !newPrint && !staleStartM73) maxOf(raw, current.progress) else raw)
+ }
status.objectOrNull("pause_resume")?.booleanOrNull("is_paused")?.let {
s = s.copy(pauseResumePaused = it)
diff --git a/app/src/main/java/works/mees/jiib/ui/extrude/ExtrudeScreen.kt b/app/src/main/java/works/mees/jiib/ui/extrude/ExtrudeScreen.kt
index 82a53014..ffd36895 100644
--- a/app/src/main/java/works/mees/jiib/ui/extrude/ExtrudeScreen.kt
+++ b/app/src/main/java/works/mees/jiib/ui/extrude/ExtrudeScreen.kt
@@ -535,25 +535,26 @@ private fun FocusGrid(
) {
val extrudeBusy = "extrude" in inFlight
OutlinedControl(
- label = "",
+ // ≤2 buttons in the row → icon+LABEL (FootButtonBar count rule, spec 2026-06-17):
+ // the two command buttons are always labelled. The visible label supplies the
+ // accessible name, so the icon carries no redundant contentDescription.
+ label = stringResource(R.string.extrude_cmd_extrude),
onClick = onExtrude,
modifier = Modifier.weight(1f).fillMaxHeight()
.alpha(if (vm.canExtrude && extrudeBusy) 0.38f else 1f),
intent = if (vm.canExtrude) Intent.Go else Intent.Danger, // R19: motion IS the screen's purpose = Go; cold = Danger lockout cue.
symbol = if (vm.canExtrude) "output_circle" else COLD_SYMBOL,
enabled = vm.canExtrude && !extrudeBusy,
- contentDescription = stringResource(R.string.extrude_cmd_extrude),
)
val retractBusy = "retract" in inFlight
OutlinedControl(
- label = "",
+ label = stringResource(R.string.extrude_cmd_retract),
onClick = onRetract,
modifier = Modifier.weight(1f).fillMaxHeight()
.alpha(if (vm.canExtrude && retractBusy) 0.38f else 1f),
intent = if (vm.canExtrude) Intent.Go else Intent.Danger, // R19: motion IS the screen's purpose = Go; cold = Danger lockout cue.
symbol = if (vm.canExtrude) "input_circle" else COLD_SYMBOL,
enabled = vm.canExtrude && !retractBusy,
- contentDescription = stringResource(R.string.extrude_cmd_retract),
)
}
},
diff --git a/app/src/main/java/works/mees/jiib/ui/printstatus/ActivePrintFormat.kt b/app/src/main/java/works/mees/jiib/ui/printstatus/ActivePrintFormat.kt
index 38df5ca5..f32f8d81 100644
--- a/app/src/main/java/works/mees/jiib/ui/printstatus/ActivePrintFormat.kt
+++ b/app/src/main/java/works/mees/jiib/ui/printstatus/ActivePrintFormat.kt
@@ -84,6 +84,21 @@ fun formatFilament(usedMm: Double, totalMm: Double?): String {
return if (total != null) "${m(used)} / ${m(total)} m" else "${m(used)} m"
}
+/**
+ * TOTAL-only Z-height for the COMPLETE state (owner 2026-07-05): the finished object's height, 2
+ * decimals to match [formatZHeight]: `"55.00 mm"`, or `"—"` when unknown. On complete the live
+ * `currentZ` is meaningless (the toolhead has parked/dropped), so only the total is shown.
+ */
+fun formatTotalZHeight(objectHeight: Double?): String =
+ if (objectHeight != null) String.format(java.util.Locale.US, "%.2f mm", objectHeight) else "—"
+
+/**
+ * TOTAL-only layers line for the COMPLETE state: `"220 layers"`, or null when the total is
+ * missing/≤ 0 (caller drops the line). No current-layer prefix — nothing changes post-print.
+ */
+fun formatTotalLayersLine(totalLayer: Int?): String? =
+ if (totalLayer != null && totalLayer > 0) "$totalLayer layers" else null
+
/** Layers line: `"5 / 220 layers"`, or null when either bound is missing/≤ 0 (caller drops the line). */
fun formatLayersLine(currentLayer: Int?, totalLayer: Int?): String? =
if (currentLayer != null && currentLayer > 0 && totalLayer != null && totalLayer > 0) {
diff --git a/app/src/main/java/works/mees/jiib/ui/printstatus/PrintStatusFocus.kt b/app/src/main/java/works/mees/jiib/ui/printstatus/PrintStatusFocus.kt
index 80807b11..dbee21a6 100644
--- a/app/src/main/java/works/mees/jiib/ui/printstatus/PrintStatusFocus.kt
+++ b/app/src/main/java/works/mees/jiib/ui/printstatus/PrintStatusFocus.kt
@@ -104,7 +104,12 @@ internal fun HomeFocus(
onPanic = onEmergencyStop,
) {
if (showDataBlock) {
- ActivePrintFocus(state = state, printMetadata = printMetadata, httpBase = httpBase)
+ ActivePrintFocus(
+ state = state,
+ printMetadata = printMetadata,
+ httpBase = httpBase,
+ isComplete = showComplete,
+ )
} else {
FocusDigest(
rows = homeDigestRows(
@@ -147,6 +152,10 @@ private fun ActivePrintFocus(
state: PrinterState,
printMetadata: PrintMetadata?,
httpBase: String,
+ // Complete reuses this data block but shows TOTALS, not live values: on a finished job the
+ // current Z / current layer are meaningless (parked toolhead), and the totals never change
+ // during a print — so Complete shows total object height + total layer count only (owner 2026-07-05).
+ isComplete: Boolean = false,
) {
val t = LocalTokens.current
val context = LocalContext.current
@@ -180,8 +189,14 @@ private fun ActivePrintFocus(
R.string.printstatus_filament,
formatFilament(state.filamentUsed, printMetadata?.filamentTotal),
)
- val zLine = stringResource(R.string.printstatus_z_height, formatZHeight(currentZ, printMetadata?.objectHeight))
- val layersLine = formatLayersLine(currentLayer, totalLayer)
+ // Complete → total object height + total layers (never change during a print); active → live values.
+ val zLine = stringResource(
+ R.string.printstatus_z_height,
+ if (isComplete) formatTotalZHeight(printMetadata?.objectHeight)
+ else formatZHeight(currentZ, printMetadata?.objectHeight),
+ )
+ val layersLine =
+ if (isComplete) formatTotalLayersLine(totalLayer) else formatLayersLine(currentLayer, totalLayer)
val dataLines: List = buildList {
if (heatersLine.isNotBlank()) add(heatersLine)
add(jobLine)
diff --git a/app/src/main/java/works/mees/jiib/ui/screen/AppSettingsScreen.kt b/app/src/main/java/works/mees/jiib/ui/screen/AppSettingsScreen.kt
index 19502eca..c870fd53 100644
--- a/app/src/main/java/works/mees/jiib/ui/screen/AppSettingsScreen.kt
+++ b/app/src/main/java/works/mees/jiib/ui/screen/AppSettingsScreen.kt
@@ -104,7 +104,9 @@ fun AppSettingsScreen(
val keepScreenOn by container.keepScreenOn.collectAsStateWithLifecycle(true)
// App-global webcam toggle (moved from per-printer, 2026-06-15) — process-scoped, durable.
- val webcamEnabled by container.webcamEnabled.collectAsStateWithLifecycle(true)
+ // Initial matches DisplayPrefs.DEFAULT_WEBCAM_ENABLED (false since 2026-07-05) so a fresh install
+ // never flashes the toggle ON before DataStore emits.
+ val webcamEnabled by container.webcamEnabled.collectAsStateWithLifecycle(false)
// Show unsupported tools toggle (probe-section Task 1) — process-scoped, durable.
val showUnsupportedTools by container.showUnsupportedTools.collectAsStateWithLifecycle(false)
diff --git a/app/src/main/java/works/mees/jiib/ui/settings/DisplayPrefs.kt b/app/src/main/java/works/mees/jiib/ui/settings/DisplayPrefs.kt
index 3a14b3fb..f87d81d3 100644
--- a/app/src/main/java/works/mees/jiib/ui/settings/DisplayPrefs.kt
+++ b/app/src/main/java/works/mees/jiib/ui/settings/DisplayPrefs.kt
@@ -14,7 +14,8 @@ import java.io.IOException
* DataStore(Preferences) persistence of the display app settings (§R2, 26.5-05): the [keepScreenOn]
* toggle (whether the shell holds `FLAG_KEEP_SCREEN_ON` on its window while foregrounded — default
* TRUE for the dedicated-display use case) and the [webcamEnabled] app-global toggle (whether the
- * webcam tile is offered app-wide — moved here from per-profile 2026-06-15, default TRUE).
+ * webcam tile is offered app-wide — moved here from per-profile 2026-06-15, default FALSE since
+ * 2026-07-05: opt-in until the webcam rotation/stability issues are fixed).
*
* Copies the [BabystepPrefs] shape EXACTLY: the [DataStore] is INJECTED (no `preferencesDataStore`
* delegate), so it is host-testable. The PRODUCTION instance (its OWN `display.preferences_pb`, NOT
@@ -49,9 +50,11 @@ class DisplayPrefs(
/**
* Whether the webcam tile/surface is offered app-wide (moved from per-profile `Profile.webcamEnabled`
- * to app-global, 2026-06-15). Default TRUE — preserves today's always-available webcam behavior; a
- * printer with no cams still hides the tile via [AppContainer.webcamTileGate] (`count > 0`).
- * Fail-safe: a read error yields the default.
+ * to app-global, 2026-06-15). Default FALSE (2026-07-05) — webcam is opt-in until the known
+ * rotation/stability issues are fixed, so a fresh install never surfaces a flaky feature; the user
+ * enables it in App Settings. A printer with no cams still hides the tile via
+ * [AppContainer.webcamTileGate] (`count > 0`) even when the toggle is on. Fail-safe: a read error
+ * yields the default.
*/
val webcamEnabled: Flow =
dataStore.data
@@ -82,7 +85,7 @@ class DisplayPrefs(
const val DEFAULT_KEEP_SCREEN_ON = true
private val KEY_KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on")
- const val DEFAULT_WEBCAM_ENABLED = true
+ const val DEFAULT_WEBCAM_ENABLED = false
private val KEY_WEBCAM_ENABLED = booleanPreferencesKey("webcam_enabled")
const val DEFAULT_SHOW_UNSUPPORTED = false
diff --git a/app/src/main/java/works/mees/jiib/ui/settings/TraceStylePrefs.kt b/app/src/main/java/works/mees/jiib/ui/settings/TraceStylePrefs.kt
index 3b06cf57..091a3efd 100644
--- a/app/src/main/java/works/mees/jiib/ui/settings/TraceStylePrefs.kt
+++ b/app/src/main/java/works/mees/jiib/ui/settings/TraceStylePrefs.kt
@@ -142,6 +142,29 @@ class TraceStylePrefs(
}
}
+ /**
+ * One-time per-[profileId] DEFAULT selection (Design A, 2026-07-05): on a printer's first visit
+ * select every currently-[available] `temperature_sensor` object so the monitoring page shows all
+ * thermometers out of the box, then the user configures DOWN. Idempotent via a per-profile sentinel
+ * ([sensorsDefaultedKey]) so it runs exactly once per printer:
+ * - sentinel already set → no-op (respect every later edit, including deselect-to-none);
+ * - sentinel unset + the profile ALREADY has selection keys → preserve them (an install configured
+ * under the old opt-in default), only stamp the sentinel;
+ * - sentinel unset + NO selection keys → brand-new: write every [available] sensor as selected.
+ * All in ONE [DataStore.edit] (mirrors the other writers' RMW discipline).
+ */
+ suspend fun applyDefaultSensorSelection(profileId: String, available: List) {
+ dataStore.edit { prefs ->
+ if (prefs[sensorsDefaultedKey(profileId)] == true) return@edit
+ val selectedScope = PREFIX_SELECTED + profileId + SEP
+ val hasExistingSelection = prefs.asMap().keys.any { it.name.startsWith(selectedScope) }
+ if (!hasExistingSelection) {
+ for (name in available) prefs[booleanPreferencesKey(selectedKey(profileId, name))] = true
+ }
+ prefs[sensorsDefaultedKey(profileId)] = true
+ }
+ }
+
/**
* One-time migration of legacy UNSCOPED `trace_*_` entries into [profileId]-scoped keys.
* Idempotent via the [MIGRATED_KEY] sentinel — runs exactly once (before any scoped keys exist, so
@@ -188,6 +211,9 @@ class TraceStylePrefs(
private const val PREFIX_VISIBLE = "trace_visible_"
private const val PREFIX_SELECTED = "trace_selected_"
+ /** Per-profile idempotence sentinel for [applyDefaultSensorSelection] (Design A, 2026-07-05). */
+ private const val PREFIX_SENSORS_DEFAULTED = "trace_sensors_defaulted_"
+
/** Separator between the profile id and the sensor name in a scoped key. */
private const val SEP = "_"
@@ -197,5 +223,9 @@ class TraceStylePrefs(
fun colorKey(profileId: String, sensorName: String) = PREFIX_COLOR + profileId + SEP + sensorName
fun visibleKey(profileId: String, sensorName: String) = PREFIX_VISIBLE + profileId + SEP + sensorName
fun selectedKey(profileId: String, sensorName: String) = PREFIX_SELECTED + profileId + SEP + sensorName
+
+ /** The per-profile sentinel key marking that [applyDefaultSensorSelection] has run for a printer. */
+ fun sensorsDefaultedKey(profileId: String) =
+ booleanPreferencesKey(PREFIX_SENSORS_DEFAULTED + profileId)
}
}
diff --git a/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureHolder.kt b/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureHolder.kt
index 80871db0..df1214e8 100644
--- a/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureHolder.kt
+++ b/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureHolder.kt
@@ -193,6 +193,13 @@ class TemperatureHolder(
}.collect { _selectedSensors.value = it }
}
}
+ // Visibility collector: rescale the Y-range whenever a trace is hidden/shown so a hidden trace
+ // stops dragging the axis bounds immediately — even when the graph is frozen (disconnected) and
+ // no new sample is arriving to re-run publishSeries. Recomputes from the last-published series.
+ scope.launch {
+ _traceVisibility.collect { recomputeYRange() }
+ }
+
// Backfill collector: seed each CURRENT ring oldest→newest the instant the one-shot read lands
// (05-03). It does NOT resolve the monitored set — that lives only in the combine collector
// below. It seeds ONCE per ring set (guarded by [seeded], reset on set change) so a re-emission
@@ -270,7 +277,27 @@ class TemperatureHolder(
private fun publishSeries() {
val snaps = rings.map { it.snapshot() }
_series.value = snaps
- _yRange.value = computeGraphYRange(snaps, _setpoints.value)
+ recomputeYRange(snaps)
+ }
+
+ /**
+ * Recompute the dynamic graph Y-range from the currently-VISIBLE traces only (bug fix): a trace
+ * hidden via [setTraceVisibility] must not drag the axis bounds, since its line isn't drawn. The
+ * visible filter mirrors the screen's `VisibleTraces` (absent-in-map = visible). Called both from
+ * [publishSeries] (each new sample) and from the visibility collector below (so toggling
+ * visibility rescales the axis immediately, even while the graph is frozen/disconnected).
+ *
+ * @param snaps the ring snapshots, parallel to [drawn]/[setpoints]; defaults to the last published
+ * [series] so the visibility collector can rescale without a fresh sample.
+ */
+ private fun recomputeYRange(snaps: List = _series.value) {
+ val vis = _traceVisibility.value
+ val sp = _setpoints.value
+ val keep = drawn.indices.filter { vis[drawn[it]] ?: true }
+ _yRange.value = computeGraphYRange(
+ keep.mapNotNull { snaps.getOrNull(it) },
+ keep.map { sp.getOrNull(it) },
+ )
}
/**
diff --git a/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureScreen.kt b/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureScreen.kt
index 43b3c75f..61031739 100644
--- a/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureScreen.kt
+++ b/app/src/main/java/works/mees/jiib/ui/temperature/TemperatureScreen.kt
@@ -608,6 +608,11 @@ private fun TemperatureContent(
val idx = legend.indexOf(sensor)
// D-14 same-hue invariant: row icon tinted to the chosen trace color.
val rowTint = traceColors[sensor.name] ?: t.seriesColor(idx)
+ // A sensor still in the list but HIDDEN from the graph gets a NEUTRAL
+ // (muted) icon (owner 2026-07-05) — the gray signals "no matching line on
+ // the graph"; the trace color would falsely imply one. Absent = visible.
+ val traceHidden = !(traceVisibility[sensor.name] ?: true)
+ val iconTint = if (traceHidden) t.text2 else rowTint
val isSelected = selectedName == sensor.name
ListRow(
selected = isSelected,
@@ -620,7 +625,7 @@ private fun TemperatureContent(
ListRowIcon(
icon = iconForSensor(sensor.name),
uDp = grid.uDp,
- tint = rowTint,
+ tint = iconTint,
)
},
trailingContent = {
diff --git a/app/src/test/java/works/mees/jiib/command/CommandReferenceDoc.kt b/app/src/test/java/works/mees/jiib/command/CommandReferenceDoc.kt
new file mode 100644
index 00000000..fe5db8ca
--- /dev/null
+++ b/app/src/test/java/works/mees/jiib/command/CommandReferenceDoc.kt
@@ -0,0 +1,149 @@
+package works.mees.jiib.command
+
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+/**
+ * Renders the human-facing **published** command reference (`docs/commands/COMMANDS.md`) from the
+ * tracked machine-readable [catalog][docs/commands/catalog.json]. It is the SINGLE render source; the
+ * committed Markdown is a pure function of the catalog, and [CommandReferenceDocDriftTest] fails the
+ * build if the checked-in file drifts from this output (regenerate with `-Djiib.regenerateDocs=true`).
+ *
+ * Scope (narrowed after pre-merge review, 2026-07-11): the registry-backed printer-control surface,
+ * not every network request the app makes. The AUTHORITATIVE set is runtime [CommandRegistry.all]
+ * (the catalog_ids represented by the registry) — NOT the catalog's
+ * `runtime_registry.registered` flag, which [CommandCatalogDriftTest] does not bind to the registry and
+ * can drift (Codex review 2026-07-06: the flag marked 65 while the registry represented ~86, so a
+ * flag-based doc silently omitted ~21 registry entries). Each represented command is joined to its
+ * catalog.json row for the
+ * display metadata (purpose/params/availability/upstream). Auxiliary direct HTTP traffic (including
+ * auth, file/thumbnail access, and webcam media) is intentionally outside this document. The broad
+ * reference surface outside the registry stays in catalog.json only. Because it is generated from the
+ * registry, the registry-backed portion cannot drift from that registry.
+ *
+ * Lives in the TEST sourceset: it is a dev/CI documentation tool, not app runtime code.
+ */
+object CommandReferenceDoc {
+
+ /**
+ * Render the Markdown document. [sentCatalogIds] is the authoritative set of catalog_ids jiib
+ * represents (`CommandRegistry.all.map { it.catalogId }`); catalog entries are filtered to it and joined
+ * for metadata. (`CommandCatalogDriftTest` already guarantees every registry id has a catalog row.)
+ */
+ fun render(catalog: JsonObject, sentCatalogIds: Set): String {
+ val commands = catalog["commands"]!!.jsonArray.map { it.jsonObject }
+ val sent = commands.filter { it.str("catalog_id") in sentCatalogIds }
+ val byCategory = sent
+ .groupBy { it.str("category") }
+ .toSortedMap()
+
+ val sb = StringBuilder()
+ sb.append(header(totalCatalog = commands.size, registeredCount = sent.size))
+ for ((category, cmds) in byCategory) {
+ sb.append("### ").append(categoryTitle(category)).append("\n\n")
+ val ordered = cmds.sortedWith(compareBy({ it.str("name") }, { it.str("catalog_id") }))
+ for (c in ordered) sb.append(renderCommand(c))
+ }
+ // Deterministic single trailing newline.
+ return sb.toString().trimEnd('\n') + "\n"
+ }
+
+ private fun header(totalCatalog: Int, registeredCount: Int): String =
+ """
+ |
+ |
+ |# jiib — Moonraker & Printer Command Reference
+ |
+ |The G-code commands and Moonraker operations represented by jiib's runtime command registry.
+ |This is the generated reference for the app's **registry-backed printer-control surface**, not
+ |an inventory of every network request the app makes.
+ |
+ |This lists the registry's **$registeredCount** commands. It intentionally omits auxiliary direct
+ |HTTP traffic such as authentication, file and thumbnail access, and webcam streams or snapshots.
+ |It also omits the broad
+ |Klipper/Moonraker reference surface jiib does *not* use; the full $totalCatalog-entry catalog
+ |(including planned and reference-only rows) lives in
+ |[`docs/commands/catalog.json`](./catalog.json).
+ |
+ |## How jiib talks to the printer
+ |
+ |jiib holds a single WebSocket to Moonraker and speaks **JSON-RPC** over it. On connect it runs a
+ |fixed handshake — `server.connection.identify` → `server.info` → `printer.objects.list` (discover
+ |what this printer exposes) → `printer.objects.query` for the subset jiib needs → then
+ |`printer.objects.subscribe` for that same subset, after which Moonraker pushes
+ |`notify_status_update` diffs that jiib merges into its live state. Printer motion and macros are
+ |sent as G-code through `printer.gcode.script`; registry-backed operations otherwise use JSON-RPC
+ |or the transport stated on their entry. Auxiliary HTTP requests are outside this reference.
+ |Which commands are offered on a given printer is gated at runtime by the **Available when**
+ |predicate shown below (evidence lives in [`printer-matrix.json`](./printer-matrix.json)).
+ |
+ |## Commands jiib sends ($registeredCount)
+ |
+ |
+ """.trimMargin()
+
+ private fun renderCommand(c: JsonObject): String {
+ val sb = StringBuilder()
+ sb.append("#### `").append(c.str("name")).append("` · ").append(transportLabel(c.str("transport")))
+ .append('\n')
+ c.strOrNull("purpose")?.let { sb.append(it).append('\n') }
+ sb.append("- **Available when:** ").append(availability(c["availability"]?.jsonObject)).append('\n')
+ paramList(c).takeIf { it.isNotEmpty() }?.let { params ->
+ sb.append("- **Parameters:** ").append(params.joinToString(", ") { "`$it`" }).append('\n')
+ }
+ c.strOrNull("upstream_url")?.let { sb.append("- **Reference:** ").append(it).append('\n') }
+ sb.append('\n')
+ return sb.toString()
+ }
+
+ /** Prefer the curated `key_params`; fall back to the full `params`. Both are string lists. */
+ private fun paramList(c: JsonObject): List {
+ val key = c["key_params"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }.orEmpty()
+ if (key.isNotEmpty()) return key
+ return c["params"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }.orEmpty()
+ }
+
+ private fun transportLabel(transport: String): String = when (transport) {
+ "gcode_script" -> "G-code (via `printer.gcode.script`)"
+ "json_rpc" -> "Moonraker JSON-RPC"
+ "spoolman_rest" -> "Spoolman REST"
+ else -> transport
+ }
+
+ private fun availability(a: JsonObject?): String {
+ if (a == null) return "Always"
+ return when (a.strOrNull("type")) {
+ null, "always" -> "Always"
+ "object_present" -> "Klipper object `${a.str("name")}` present"
+ "any_object_present" -> {
+ val names = a["names"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull }.orEmpty()
+ "any of " + names.joinToString(", ") { "`$it`" } + " present"
+ }
+ "macro_present" -> "macro `${a.str("name").uppercase()}` present"
+ "component_present" -> "Moonraker component `${a.str("name")}` present"
+ "gcode_command_present" -> "G-code `${a.str("name").uppercase()}` present"
+ else -> a.str("type")
+ }
+ }
+
+ /** `heater_generic chamber` → "Heater Generic Chamber"; `print_control` → "Print Control". */
+ private fun categoryTitle(raw: String): String =
+ raw.split(Regex("[\\s_]+"))
+ .filter { it.isNotEmpty() }
+ .joinToString(" ") { w -> w.replaceFirstChar { it.uppercase() } }
+
+ private fun JsonObject.str(key: String): String =
+ this[key]?.jsonPrimitive?.contentOrNull ?: error("Missing string key '$key' in catalog entry $this")
+
+ private fun JsonObject.strOrNull(key: String): String? =
+ this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
+}
diff --git a/app/src/test/java/works/mees/jiib/command/CommandReferenceDocDriftTest.kt b/app/src/test/java/works/mees/jiib/command/CommandReferenceDocDriftTest.kt
new file mode 100644
index 00000000..b7bf652e
--- /dev/null
+++ b/app/src/test/java/works/mees/jiib/command/CommandReferenceDocDriftTest.kt
@@ -0,0 +1,59 @@
+package works.mees.jiib.command
+
+import kotlinx.serialization.json.jsonObject
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import works.mees.jiib.net.MoonrakerJson
+import java.io.File
+
+/**
+ * Drift guard for the PUBLISHED human command reference `docs/commands/COMMANDS.md`. The doc is a pure
+ * rendering of the tracked `docs/commands/catalog.json` (via [CommandReferenceDoc]); this test fails the
+ * build if the committed Markdown drifts from that render — so the code, the catalog, and the published
+ * doc stay in lockstep (the catalog↔registry side is guarded by [CommandCatalogDriftTest]).
+ *
+ * REGENERATE after a legitimate catalog/registry change:
+ * ```
+ * ./gradlew :app:testDebugUnitTest --tests '*CommandReferenceDocDriftTest' -Djiib.regenerateDocs=true
+ * ```
+ * That writes the fresh doc (and passes); commit the result. Without the flag the test only compares.
+ */
+class CommandReferenceDocDriftTest {
+
+ @Test
+ fun publishedDocMatchesCatalog() {
+ val catalogFile = docsFile("docs/commands/catalog.json")
+ val catalog = MoonrakerJson.parseToJsonElement(catalogFile.readText()).jsonObject
+ // Authoritative set = the runtime registry (what jiib actually sends), NOT the catalog's
+ // drift-prone `registered` flag (Codex review 2026-07-06).
+ val sentCatalogIds = CommandRegistry.all.map { it.catalogId }.toSet()
+ val expected = CommandReferenceDoc.render(catalog, sentCatalogIds)
+
+ // Sibling of catalog.json — resolves even on first generation (the file need not pre-exist).
+ val docFile = File(catalogFile.parentFile, "COMMANDS.md")
+
+ if (System.getProperty("jiib.regenerateDocs") == "true") {
+ docFile.writeText(expected)
+ return
+ }
+
+ assertEquals(
+ "docs/commands/COMMANDS.md is stale vs catalog.json — regenerate with " +
+ "-Djiib.regenerateDocs=true (see CommandReferenceDocDriftTest).",
+ expected,
+ docFile.takeIf { it.isFile }?.readText() ?: "",
+ )
+ }
+
+ /** Walk up from the test working dir to the repo file (mirrors CommandCatalogDriftTest.docsFile). */
+ private fun docsFile(path: String): File {
+ val userDir = requireNotNull(System.getProperty("user.dir")) { "user.dir system property missing" }
+ var dir: File? = File(userDir).canonicalFile
+ while (dir != null) {
+ val candidate = File(dir, path)
+ if (candidate.isFile) return candidate
+ dir = dir.parentFile
+ }
+ error("Fixture $path not found from ${System.getProperty("user.dir")}")
+ }
+}
diff --git a/app/src/test/java/works/mees/jiib/state/PrinterStateReducerTest.kt b/app/src/test/java/works/mees/jiib/state/PrinterStateReducerTest.kt
index 3c9d0aad..eb4c872a 100644
--- a/app/src/test/java/works/mees/jiib/state/PrinterStateReducerTest.kt
+++ b/app/src/test/java/works/mees/jiib/state/PrinterStateReducerTest.kt
@@ -356,6 +356,110 @@ class PrinterStateReducerTest {
assertEquals(0.5, afterProgressOnly.progress, 0.0001)
}
+ // --- Progress source: prefer slicer M73 (display_status), fall back to file (virtual_sdcard),
+ // persisted separately + monotonic clamp so the ring doesn't flip/jitter (2026-07-06). ---
+
+ private fun status(json: String) = MoonrakerJson.parseToJsonElement(json).jsonObject
+
+ @Test
+ fun progressPrefersSlicerM73AndDoesNotFlipOnSdcardOnlyDiff() {
+ var s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"display_status":{"progress":0.40}}"""),
+ )
+ assertEquals(0.40, s.progress, 1e-6)
+ // A file-position-only diff (43%) must NOT flip the ring off the slicer estimate.
+ s = reduceDiff(s, status("""{"virtual_sdcard":{"progress":0.43}}"""))
+ assertEquals("sdcard-only diff must not override the slicer M73 progress", 0.40, s.progress, 1e-6)
+ }
+
+ @Test
+ fun progressNeverTicksBackwardDuringPrint() {
+ var s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"display_status":{"progress":0.50}}"""),
+ )
+ assertEquals(0.50, s.progress, 1e-6)
+ // M73 revises the estimate DOWN — the ring holds, never ticks backward.
+ s = reduceDiff(s, status("""{"display_status":{"progress":0.42}}"""))
+ assertEquals(0.50, s.progress, 1e-6)
+ // Forward again advances normally.
+ s = reduceDiff(s, status("""{"display_status":{"progress":0.55}}"""))
+ assertEquals(0.55, s.progress, 1e-6)
+ }
+
+ @Test
+ fun progressResetsWhenANewPrintStarts() {
+ var s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"display_status":{"progress":0.99}}"""),
+ )
+ s = reduceDiff(s, status("""{"print_stats":{"state":"complete"}}"""))
+ // New job (printing + new filename): the ring resets, never shows the prior job's 99%.
+ s = reduceDiff(
+ s,
+ status("""{"print_stats":{"state":"printing","filename":"b.gcode"},"virtual_sdcard":{"progress":0.02}}"""),
+ )
+ assertEquals(0.02, s.progress, 1e-6)
+ }
+
+ @Test
+ fun progressIgnoresStaleSlicerM73AtNewPrintStart() {
+ // Job A finishes near 100% (M73 0.99). Job B then starts via the queue (new filename) while
+ // Klipper still momentarily holds A's stale M73 0.99, but B's file position is a fresh 2%. The
+ // ring must follow the fresh file position, not the stale 99% (reconnect/back-to-back guard).
+ var s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"display_status":{"progress":0.99}}"""),
+ )
+ s = reduceDiff(
+ s,
+ status(
+ """{"print_stats":{"state":"printing","filename":"b.gcode"},""" +
+ """"virtual_sdcard":{"progress":0.02},"display_status":{"progress":0.99}}""",
+ ),
+ )
+ assertEquals(0.02, s.progress, 1e-6)
+
+ // The rejected prior-job M73 must be discarded, not merely masked while file progress is <10%.
+ // At the settle boundary it must continue following file progress rather than resurrecting 99%.
+ s = reduceDiff(s, status("""{"virtual_sdcard":{"progress":0.10}}"""))
+ assertEquals(0.10, s.progress, 1e-6)
+
+ // A later plausible M73 is fresh and becomes the preferred source normally.
+ s = reduceDiff(s, status("""{"display_status":{"progress":0.12}}"""))
+ assertEquals(0.12, s.progress, 1e-6)
+ }
+
+ @Test
+ fun progressCanCorrectStaleM73WhenFilePositionArrivesInALaterDiff() {
+ var s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"display_status":{"progress":0.99}}"""),
+ )
+ // A back-to-back job starts while only the stale display_status value accompanies print_stats.
+ s = reduceDiff(
+ s,
+ status("""{"print_stats":{"state":"printing","filename":"b.gcode"},"display_status":{"progress":0.99}}"""),
+ )
+ assertEquals(0.99, s.progress, 1e-6)
+
+ // File position arrives separately. Detecting the stale source must permit the correction down;
+ // the within-print monotonic clamp must not preserve the bogus prior-job 99%.
+ s = reduceDiff(s, status("""{"virtual_sdcard":{"progress":0.02}}"""))
+ assertEquals(0.02, s.progress, 1e-6)
+ }
+
+ @Test
+ fun progressFallsBackToFilePositionWhenNoSlicerM73() {
+ // No display_status ever → file position (virtual_sdcard) drives the ring.
+ val s = reduceDiff(
+ PrinterState(),
+ status("""{"print_stats":{"state":"printing","filename":"a.gcode"},"virtual_sdcard":{"progress":0.30}}"""),
+ )
+ assertEquals(0.30, s.progress, 1e-6)
+ }
+
// --- Phase-9: the five calibration live objects, fed the REAL Ender-5-Plus fixtures (09-01) ---
/**
diff --git a/app/src/test/java/works/mees/jiib/ui/printstatus/ActivePrintFormatTest.kt b/app/src/test/java/works/mees/jiib/ui/printstatus/ActivePrintFormatTest.kt
index cf29ddc0..79553088 100644
--- a/app/src/test/java/works/mees/jiib/ui/printstatus/ActivePrintFormatTest.kt
+++ b/app/src/test/java/works/mees/jiib/ui/printstatus/ActivePrintFormatTest.kt
@@ -101,6 +101,19 @@ class ActivePrintFormatTest {
assertNull(formatLayersLine(5, 0))
}
+ @Test fun total_z_height_line() {
+ // Complete state (owner 2026-07-05): show the TOTAL/object height only — no live "current /".
+ assertEquals("55.00 mm", formatTotalZHeight(55.0))
+ assertEquals("—", formatTotalZHeight(null)) // no metadata height → unknown
+ }
+
+ @Test fun total_layers_line() {
+ // Complete state: total layer count only, no current-layer prefix.
+ assertEquals("220 layers", formatTotalLayersLine(220))
+ assertNull(formatTotalLayersLine(null))
+ assertNull(formatTotalLayersLine(0))
+ }
+
@Test fun filament_line() {
assertEquals("5.2 / 12.3 m", formatFilament(5200.0, 12345.0))
assertEquals("5.2 m", formatFilament(5200.0, null)) // no total → used alone
diff --git a/app/src/test/java/works/mees/jiib/ui/settings/DisplayPrefsTest.kt b/app/src/test/java/works/mees/jiib/ui/settings/DisplayPrefsTest.kt
index be869a4b..c7ea660a 100644
--- a/app/src/test/java/works/mees/jiib/ui/settings/DisplayPrefsTest.kt
+++ b/app/src/test/java/works/mees/jiib/ui/settings/DisplayPrefsTest.kt
@@ -113,10 +113,12 @@ class DisplayPrefsTest {
}
@Test
- fun emptyStore_defaultsWebcamEnabledTrue() = runBlocking {
+ fun emptyStore_defaultsWebcamEnabledFalse() = runBlocking {
+ // Webcam ships DEFAULT-OFF (2026-07-05) until the rotation/stability issues are fixed — the
+ // user opts in per-install via App Settings rather than being handed a known-flaky feature.
val (dataStore, _) = newDataStore()
val prefs = DisplayPrefs(dataStore)
- assertTrue("default webcamEnabled is true", prefs.webcamEnabled.first())
+ assertFalse("default webcamEnabled is false", prefs.webcamEnabled.first())
}
@Test
diff --git a/app/src/test/java/works/mees/jiib/ui/settings/TraceStylePrefsSelectionTest.kt b/app/src/test/java/works/mees/jiib/ui/settings/TraceStylePrefsSelectionTest.kt
index 76760884..69314aa5 100644
--- a/app/src/test/java/works/mees/jiib/ui/settings/TraceStylePrefsSelectionTest.kt
+++ b/app/src/test/java/works/mees/jiib/ui/settings/TraceStylePrefsSelectionTest.kt
@@ -132,6 +132,50 @@ class TraceStylePrefsSelectionTest {
assertTrue("profile B has no visibility override", prefs.traceVisibility(pidB).first().isEmpty())
}
+ @Test
+ fun applyDefaultSensorSelection_brandNewProfile_selectsAllAvailable() = runBlocking {
+ // Design A (2026-07-05): a printer's first visit defaults EVERY available temperature_sensor to
+ // selected so the monitoring page shows all thermometers out of the box.
+ val prefs = TraceStylePrefs(memDataStore())
+ val available = listOf("temperature_sensor chamber", "temperature_sensor mcu")
+ prefs.applyDefaultSensorSelection(pidA, available)
+ assertEquals(available.toSet(), prefs.selectedSensors(pidA).first())
+ }
+
+ @Test
+ fun applyDefaultSensorSelection_isIdempotent_respectsLaterDeselect() = runBlocking {
+ // Once defaulted, the per-profile sentinel stops re-defaulting — a later deselect (and even a
+ // deselect-to-none) is honored across reconnects; the default must never claw a sensor back.
+ val prefs = TraceStylePrefs(memDataStore())
+ val available = listOf("temperature_sensor chamber", "temperature_sensor mcu")
+ prefs.applyDefaultSensorSelection(pidA, available)
+ prefs.setSensorSelected(pidA, "temperature_sensor mcu", false)
+ assertEquals(setOf("temperature_sensor chamber"), prefs.selectedSensors(pidA).first())
+
+ prefs.applyDefaultSensorSelection(pidA, available) // e.g. a reconnect re-runs the default pass
+ assertEquals(
+ "sentinel stops re-defaulting; the deselected sensor stays off",
+ setOf("temperature_sensor chamber"),
+ prefs.selectedSensors(pidA).first(),
+ )
+ }
+
+ @Test
+ fun applyDefaultSensorSelection_preservesExistingOptInSelection() = runBlocking {
+ // An install configured under the OLD opt-in default (one sensor explicitly selected) must be
+ // PRESERVED — the default pass sees existing selection keys and neither clears nor force-adds.
+ val prefs = TraceStylePrefs(memDataStore())
+ prefs.setSensorSelected(pidA, "temperature_sensor chamber", true)
+ val available =
+ listOf("temperature_sensor chamber", "temperature_sensor mcu", "temperature_sensor exhaust")
+ prefs.applyDefaultSensorSelection(pidA, available)
+ assertEquals(
+ "existing selection preserved, the other available sensors not force-added",
+ setOf("temperature_sensor chamber"),
+ prefs.selectedSensors(pidA).first(),
+ )
+ }
+
@Test
fun migrateUnscopedTo_copiesLegacyKeysToProfile_removesLegacy_idempotent() = runBlocking {
val ds = memDataStore()
diff --git a/app/src/test/java/works/mees/jiib/ui/temperature/TemperatureHolderTest.kt b/app/src/test/java/works/mees/jiib/ui/temperature/TemperatureHolderTest.kt
index ad1dacbf..3e2e01e6 100644
--- a/app/src/test/java/works/mees/jiib/ui/temperature/TemperatureHolderTest.kt
+++ b/app/src/test/java/works/mees/jiib/ui/temperature/TemperatureHolderTest.kt
@@ -163,6 +163,34 @@ class TemperatureHolderTest {
assertEquals(4, holder.setpoints.value.size)
}
+ @Test
+ fun hiddenTraceIsExcludedFromTheGraphYRange() = runTest(UnconfinedTestDispatcher()) {
+ val store = PrinterStateStore(backgroundScope)
+ val holder = TemperatureHolder(backgroundScope, store)
+ store.setCapabilities(Capabilities(hasBed = true, heaters = listOf("extruder", "heater_bed")))
+
+ store.seed(
+ PrinterState(
+ heaters = heaters(
+ "extruder" to HeaterState(temperature = 200.0, target = 210.0),
+ "heater_bed" to HeaterState(temperature = 58.0, target = 60.0),
+ ),
+ ),
+ )
+ runCurrent()
+
+ // Both traces visible: the range reaches the hot nozzle's 210° setpoint (max 210 → +5 → 215).
+ assertEquals("hi includes the visible nozzle setpoint", 215f, holder.yRange.value.endInclusive, 0.001f)
+
+ // Hide the nozzle trace — a hidden trace's samples + setpoint must NOT drive the graph bounds.
+ holder.setTraceVisibility("extruder", false)
+ runCurrent()
+
+ // Only the bed (58°, target 60°) remains → 50..65 (53 floors to 50, 65 ceils to 65), NOT up to 215.
+ assertEquals("lo from the visible bed only", 50f, holder.yRange.value.start, 0.001f)
+ assertEquals("hi from the visible bed only, nozzle excluded", 65f, holder.yRange.value.endInclusive, 0.001f)
+ }
+
@Test
fun holderConsumesStoreFlowAndExposesStateFlows() = runTest(UnconfinedTestDispatcher()) {
val store = PrinterStateStore(backgroundScope)
diff --git a/docs/commands/COMMANDS.md b/docs/commands/COMMANDS.md
new file mode 100644
index 00000000..9ac2b4de
--- /dev/null
+++ b/docs/commands/COMMANDS.md
@@ -0,0 +1,545 @@
+
+
+# jiib — Moonraker & Printer Command Reference
+
+The G-code commands and Moonraker operations represented by jiib's runtime command registry.
+This is the generated reference for the app's **registry-backed printer-control surface**, not
+an inventory of every network request the app makes.
+
+This lists the registry's **86** commands. It intentionally omits auxiliary direct
+HTTP traffic such as authentication, file and thumbnail access, and webcam streams or snapshots.
+It also omits the broad
+Klipper/Moonraker reference surface jiib does *not* use; the full 338-entry catalog
+(including planned and reference-only rows) lives in
+[`docs/commands/catalog.json`](./catalog.json).
+
+## How jiib talks to the printer
+
+jiib holds a single WebSocket to Moonraker and speaks **JSON-RPC** over it. On connect it runs a
+fixed handshake — `server.connection.identify` → `server.info` → `printer.objects.list` (discover
+what this printer exposes) → `printer.objects.query` for the subset jiib needs → then
+`printer.objects.subscribe` for that same subset, after which Moonraker pushes
+`notify_status_update` diffs that jiib merges into its live state. Printer motion and macros are
+sent as G-code through `printer.gcode.script`; registry-backed operations otherwise use JSON-RPC
+or the transport stated on their entry. Auxiliary HTTP requests are outside this reference.
+Which commands are offered on a given printer is gated at runtime by the **Available when**
+predicate shown below (evidence lives in [`printer-matrix.json`](./printer-matrix.json)).
+
+## Commands jiib sends (86)
+
+### Action
+
+#### `printer.emergency_stop` · Moonraker JSON-RPC
+Emergency-stop Klippy from Dinghy confirm guard.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+### Auth
+
+#### `access.oneshot_token` · Moonraker JSON-RPC
+Request a short-lived token for websocket/download access when auth is required.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/authorization/
+
+### Calibration
+
+#### `ACCEPT` · G-code (via `printer.gcode.script`)
+Klipper `ACCEPT` — accept the current Z and conclude the manual-probe session (D-01).
+- **Available when:** Klipper object `manual_probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `BED_MESH_CALIBRATE` · G-code (via `printer.gcode.script`)
+Klipper `BED_MESH_CALIBRATE` command from the official G-Code reference.
+- **Available when:** Klipper object `bed_mesh` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `BED_MESH_PROFILE LOAD` · G-code (via `printer.gcode.script`)
+Klipper `BED_MESH_PROFILE LOAD=` — load a saved mesh profile (D-10).
+- **Available when:** Klipper object `bed_mesh` present
+- **Parameters:** `LOAD`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `BED_MESH_PROFILE REMOVE` · G-code (via `printer.gcode.script`)
+Klipper `BED_MESH_PROFILE REMOVE=` — remove a saved mesh profile (D-10).
+- **Available when:** Klipper object `bed_mesh` present
+- **Parameters:** `REMOVE`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `BED_MESH_PROFILE SAVE` · G-code (via `printer.gcode.script`)
+Klipper `BED_MESH_PROFILE SAVE=` — save the active mesh into a named profile (D-10).
+- **Available when:** Klipper object `bed_mesh` present
+- **Parameters:** `SAVE`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `PROBE` · G-code (via `printer.gcode.script`)
+Klipper `PROBE` command from the official G-Code reference.
+- **Available when:** Klipper object `probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `PROBE_ACCURACY` · G-code (via `printer.gcode.script`)
+Klipper `PROBE_ACCURACY` command from the official G-Code reference.
+- **Available when:** Klipper object `probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `PROBE_CALIBRATE` · G-code (via `printer.gcode.script`)
+Klipper `PROBE_CALIBRATE` command from the official G-Code reference.
+- **Available when:** Klipper object `probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `QUAD_GANTRY_LEVEL` · G-code (via `printer.gcode.script`)
+Klipper `QUAD_GANTRY_LEVEL` command from the official G-Code reference.
+- **Available when:** Klipper object `quad_gantry_level` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `QUERY_PROBE` · G-code (via `printer.gcode.script`)
+Klipper `QUERY_PROBE` command from the official G-Code reference.
+- **Available when:** Klipper object `probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SCREWS_TILT_CALCULATE` · G-code (via `printer.gcode.script`)
+Klipper `SCREWS_TILT_CALCULATE` command from the official G-Code reference.
+- **Available when:** Klipper object `screws_tilt_adjust` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `TESTZ` · G-code (via `printer.gcode.script`)
+Klipper `TESTZ Z=` — nudge Z within the manual-probe session (D-01).
+- **Available when:** Klipper object `manual_probe` present
+- **Parameters:** `Z`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `Z_TILT_ADJUST` · G-code (via `printer.gcode.script`)
+Klipper `Z_TILT_ADJUST` command from the official G-Code reference.
+- **Available when:** Klipper object `z_tilt` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Config
+
+#### `SAVE_CONFIG` · G-code (via `printer.gcode.script`)
+Klipper `SAVE_CONFIG` command from the official G-Code reference.
+- **Available when:** Always
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `Z_OFFSET_APPLY_* + SAVE_CONFIG` · G-code (via `printer.gcode.script`)
+Compound: apply the live Z-offset (Z_OFFSET_APPLY_PROBE/ENDSTOP) then SAVE_CONFIG, as one ordered gcode block (Live Z-Offset Save & Restart).
+- **Available when:** Always
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Connection
+
+#### `server.connection.identify` · Moonraker JSON-RPC
+Identify Dinghy as a Moonraker websocket client before subscriptions.
+- **Available when:** Always
+- **Parameters:** `client_name`, `version`, `type`, `url`, `api_key`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/server/
+
+### Console
+
+#### `server.gcode_store` · Moonraker JSON-RPC
+Read cached G-Code responses for console backfill.
+- **Available when:** Always
+- **Parameters:** `count`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/server/
+
+### Extrusion
+
+#### `G1 E` · G-code (via `printer.gcode.script`)
+Dinghy bounded extrude/retract wrapper.
+- **Available when:** Klipper object `extruder` present
+- **Parameters:** `E`, `F`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `T` · G-code (via `printer.gcode.script`)
+Select active tool by numeric index.
+- **Available when:** Klipper object `extruder` present
+- **Parameters:** `tool index`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Files
+
+#### `server.files.delete_file` · Moonraker JSON-RPC
+Delete a file from an allowed Moonraker root.
+- **Available when:** Moonraker component `file_manager` present
+- **Parameters:** `path`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/file_manager/
+
+#### `server.files.get_directory` · Moonraker JSON-RPC
+Browse one directory without walking the entire root.
+- **Available when:** Moonraker component `file_manager` present
+- **Parameters:** `path`, `extended`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/file_manager/
+
+#### `server.files.metadata` · Moonraker JSON-RPC
+Read slicer metadata and thumbnails for a selected gcode file.
+- **Available when:** Moonraker component `file_manager` present
+- **Parameters:** `filename`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/file_manager/
+
+#### `server.files.thumbnails` · Moonraker JSON-RPC
+Read thumbnail details for a gcode file.
+- **Available when:** Moonraker component `file_manager` present
+- **Parameters:** `filename`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/file_manager/
+
+### History
+
+#### `server.history.list` · Moonraker JSON-RPC
+Read recent print history for the idle Status last-job card.
+- **Available when:** Moonraker component `history` present
+- **Parameters:** `limit`, `order`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/history/
+
+#### `server.temperature_store` · Moonraker JSON-RPC
+Read cached temperature history for graph backfill.
+- **Available when:** Moonraker component `history` present
+- **Parameters:** `include_monitors`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/server/
+
+### Klipper Module
+
+#### `ABORT` · G-code (via `printer.gcode.script`)
+Klipper `ABORT` command from the official G-Code reference.
+- **Available when:** Klipper object `manual_probe` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `LDC_CALIBRATE_DRIVE_CURRENT` · G-code (via `printer.gcode.script`)
+Klipper `LDC_CALIBRATE_DRIVE_CURRENT` command from the official G-Code reference.
+- **Available when:** G-code `LDC_CALIBRATE_DRIVE_CURRENT` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `PROBE_EDDY_CURRENT_CALIBRATE` · G-code (via `printer.gcode.script`)
+Klipper `PROBE_EDDY_CURRENT_CALIBRATE` command from the official G-Code reference.
+- **Available when:** G-code `PROBE_EDDY_CURRENT_CALIBRATE` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `PROBE_EDDY_CURRENT_TAP_CALIBRATE` · G-code (via `printer.gcode.script`)
+Klipper `PROBE_EDDY_CURRENT_TAP_CALIBRATE` command from the official G-Code reference.
+- **Available when:** G-code `PROBE_EDDY_CURRENT_TAP_CALIBRATE` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_FILAMENT_SENSOR` · G-code (via `printer.gcode.script`)
+Klipper `SET_FILAMENT_SENSOR` command from the official G-Code reference.
+- **Available when:** G-code `SET_FILAMENT_SENSOR` present
+- **Parameters:** `SENSOR`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_GCODE_OFFSET` · G-code (via `printer.gcode.script`)
+Klipper `SET_GCODE_OFFSET` command from the official G-Code reference.
+- **Available when:** G-code `SET_GCODE_OFFSET` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_GCODE_OFFSET (clear)` · G-code (via `printer.gcode.script`)
+Klipper `SET_GCODE_OFFSET Z=0 MOVE=1` — clear the live Z babystep offset.
+- **Available when:** Klipper object `gcode_move` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_LED` · G-code (via `printer.gcode.script`)
+Klipper `SET_LED` command from the official G-Code reference.
+- **Available when:** G-code `SET_LED` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_PRESSURE_ADVANCE` · G-code (via `printer.gcode.script`)
+Klipper `SET_PRESSURE_ADVANCE` command from the official G-Code reference.
+- **Available when:** G-code `SET_PRESSURE_ADVANCE` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_RETRACTION` · G-code (via `printer.gcode.script`)
+Klipper `SET_RETRACTION` command from the official G-Code reference.
+- **Available when:** G-code `SET_RETRACTION` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_TEMPERATURE_FAN_TARGET` · G-code (via `printer.gcode.script`)
+Klipper `SET_TEMPERATURE_FAN_TARGET` command from the official G-Code reference.
+- **Available when:** G-code `SET_TEMPERATURE_FAN_TARGET` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_VELOCITY_LIMIT` · G-code (via `printer.gcode.script`)
+Klipper `SET_VELOCITY_LIMIT` command from the official G-Code reference.
+- **Available when:** G-code `SET_VELOCITY_LIMIT` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `Z_ENDSTOP_CALIBRATE` · G-code (via `printer.gcode.script`)
+Klipper `Z_ENDSTOP_CALIBRATE` command from the official G-Code reference.
+- **Available when:** G-code `Z_ENDSTOP_CALIBRATE` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `Z_OFFSET_APPLY_ENDSTOP` · G-code (via `printer.gcode.script`)
+Klipper `Z_OFFSET_APPLY_ENDSTOP` command from the official G-Code reference.
+- **Available when:** G-code `Z_OFFSET_APPLY_ENDSTOP` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `Z_OFFSET_APPLY_PROBE` · G-code (via `printer.gcode.script`)
+Klipper `Z_OFFSET_APPLY_PROBE` command from the official G-Code reference.
+- **Available when:** G-code `Z_OFFSET_APPLY_PROBE` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Machine
+
+#### `machine.proc_stats` · Moonraker JSON-RPC
+Moonraker `machine.proc_stats` operation from the official external API documentation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/machine/
+
+#### `machine.reboot` · Moonraker JSON-RPC
+Moonraker `machine.reboot` operation from the official external API documentation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/machine/
+
+#### `machine.services.restart` · Moonraker JSON-RPC
+Moonraker `machine.services.restart` operation from the official external API documentation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/machine/
+
+#### `machine.shutdown` · Moonraker JSON-RPC
+Moonraker `machine.shutdown` operation from the official external API documentation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/machine/
+
+#### `machine.system_info` · Moonraker JSON-RPC
+Moonraker `machine.system_info` operation from the official external API documentation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/machine/
+
+### Macro
+
+#### `LOAD_FILAMENT` · G-code (via `printer.gcode.script`)
+Run the user-defined `LOAD_FILAMENT` macro when present.
+- **Available when:** macro `LOAD_FILAMENT` present
+- **Reference:** https://www.klipper3d.org/Command_Templates.html
+
+#### `UNLOAD_FILAMENT` · G-code (via `printer.gcode.script`)
+Run the user-defined `UNLOAD_FILAMENT` macro when present.
+- **Available when:** macro `UNLOAD_FILAMENT` present
+- **Reference:** https://www.klipper3d.org/Command_Templates.html
+
+### Motion
+
+#### `FORCE_MOVE` · G-code (via `printer.gcode.script`)
+Klipper `FORCE_MOVE` command from the official G-Code reference.
+- **Available when:** G-code `FORCE_MOVE` present
+- **Parameters:** `STEPPER`, `DISTANCE`, `VELOCITY`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `G1 absolute move` · G-code (via `printer.gcode.script`)
+Dinghy bounded absolute move-to wrapper (Move Hub touch/scrubber moves).
+- **Available when:** Klipper object `toolhead` present
+- **Parameters:** `X`, `Y`, `Z`, `F`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `G1 relative jog` · G-code (via `printer.gcode.script`)
+Dinghy bounded relative jog wrapper.
+- **Available when:** Klipper object `toolhead` present
+- **Parameters:** `X`, `Y`, `Z`, `F`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `G28` · G-code (via `printer.gcode.script`)
+Dinghy home-all command.
+- **Available when:** Klipper object `toolhead` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `G28 ` · G-code (via `printer.gcode.script`)
+Dinghy single-axis home command.
+- **Available when:** Klipper object `toolhead` present
+- **Parameters:** `X`, `Y`, `Z`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `G28 X Y` · G-code (via `printer.gcode.script`)
+Dinghy home-X/Y command that does not home Z.
+- **Available when:** Klipper object `toolhead` present
+- **Parameters:** `X`, `Y`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `M106` · G-code (via `printer.gcode.script`)
+Dinghy Fine-Tune part-cooling fan live-adjust (D-11).
+- **Available when:** Klipper object `fan` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `M84` · G-code (via `printer.gcode.script`)
+Dinghy disable-steppers confirm-guard action.
+- **Available when:** Klipper object `toolhead` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_KINEMATIC_POSITION + G1` · G-code (via `printer.gcode.script`)
+Explicit unhomed override jog path.
+- **Available when:** G-code `FORCE_MOVE` present
+- **Parameters:** `X`, `Y`, `Z`, `F`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Output
+
+#### `SET_FAN_SPEED` · G-code (via `printer.gcode.script`)
+Set a generic (`fan_generic`) fan speed; BARE section name (HIGH-1).
+- **Available when:** Always
+- **Parameters:** `FAN`, `SPEED`
+- **Reference:** https://www.klipper3d.org/Config_Reference.html#fan_generic
+
+#### `SET_LED` · G-code (via `printer.gcode.script`)
+Set an LED/neopixel color; BARE section name (HIGH-1); D-12 Off = all-zero.
+- **Available when:** Always
+- **Parameters:** `LED`, `RED`, `GREEN`, `BLUE`
+- **Reference:** https://www.klipper3d.org/Config_Reference.html#led
+
+#### `SET_PIN` · G-code (via `printer.gcode.script`)
+Set a digital or PWM `output_pin` (also serves `pwm_tool`); BARE section name (HIGH-1).
+- **Available when:** Always
+- **Parameters:** `PIN`, `VALUE`
+- **Reference:** https://www.klipper3d.org/Config_Reference.html#output_pin
+
+#### `SET_SERVO` · G-code (via `printer.gcode.script`)
+Set a servo angle, or disable it via `WIDTH=0`; BARE section name (HIGH-1).
+- **Available when:** Always
+- **Parameters:** `SERVO`, `ANGLE`
+- **Reference:** https://www.klipper3d.org/Config_Reference.html#servo
+
+### Print Control
+
+#### `SDCARD_RESET_FILE` · G-code (via `printer.gcode.script`)
+Klipper `SDCARD_RESET_FILE` command from the official G-Code reference.
+- **Available when:** Klipper object `virtual_sdcard` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `printer.print.cancel` · Moonraker JSON-RPC
+Cancel the active print.
+- **Available when:** Klipper object `pause_resume` present
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.print.pause` · Moonraker JSON-RPC
+Pause the active print.
+- **Available when:** Klipper object `pause_resume` present
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.print.resume` · Moonraker JSON-RPC
+Resume a paused print.
+- **Available when:** Klipper object `pause_resume` present
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.print.start` · Moonraker JSON-RPC
+Start printing a gcode file.
+- **Available when:** Klipper object `virtual_sdcard` present
+- **Parameters:** `filename`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+### Printer
+
+#### `printer.info` · Moonraker JSON-RPC
+Read Klippy connection and printer software state.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.query_endstops.status` · Moonraker JSON-RPC
+Read the current trigger state (open/TRIGGERED) of each configured endstop.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+### Recovery
+
+#### `printer.firmware_restart` · Moonraker JSON-RPC
+Restart Klipper firmware from recovery UI.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.restart` · Moonraker JSON-RPC
+Restart Klippy host process from recovery UI.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+### Server
+
+#### `server.info` · Moonraker JSON-RPC
+Read server metadata and component names for live component predicates.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/server/
+
+### Spoolman
+
+#### `server.spoolman.get_spool_id` · Moonraker JSON-RPC
+Read active spool ID from Moonraker Spoolman integration.
+- **Available when:** Moonraker component `spoolman` present
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/integrations/
+
+#### `server.spoolman.post_spool_id` · Moonraker JSON-RPC
+Set active spool ID through Moonraker Spoolman integration.
+- **Available when:** Moonraker component `spoolman` present
+- **Parameters:** `spool_id`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/integrations/
+
+#### `server.spoolman.proxy` · Moonraker JSON-RPC
+Proxy Spoolman REST requests through Moonraker.
+- **Available when:** Moonraker component `spoolman` present
+- **Parameters:** `request_method`, `path`, `body`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/integrations/
+
+#### `server.spoolman.status` · Moonraker JSON-RPC
+Read Moonraker Spoolman integration status.
+- **Available when:** Moonraker component `spoolman` present
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/integrations/
+
+### State
+
+#### `printer.objects.list` · Moonraker JSON-RPC
+List available Klipper status objects for live capability derivation.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.objects.query` · Moonraker JSON-RPC
+Read a point-in-time status snapshot for selected Klipper objects.
+- **Available when:** Always
+- **Parameters:** `objects`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+#### `printer.objects.subscribe` · Moonraker JSON-RPC
+Subscribe to printer status diffs for selected Klipper objects.
+- **Available when:** Always
+- **Parameters:** `objects`
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/printer/
+
+### Temperature
+
+#### `APPLY_HEAT_PRESET` · G-code (via `printer.gcode.script`)
+Apply a per-printer Heat Preset — one SET_HEATER_TEMPERATURE / SET_TEMPERATURE_FAN_TARGET line per heater in the preset's sparse setpoint map.
+- **Available when:** Klipper object `extruder` present
+- **Parameters:** `HEATER`, `TARGET`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_HEATER_TEMPERATURE` · G-code (via `printer.gcode.script`)
+Klipper `SET_HEATER_TEMPERATURE` command from the official G-Code reference.
+- **Available when:** any of `extruder`, `heater_bed`, `heater_generic chamber` present
+- **Parameters:** `HEATER`, `TARGET`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `SET_TEMPERATURE_FAN_TARGET` · G-code (via `printer.gcode.script`)
+Klipper `SET_TEMPERATURE_FAN_TARGET` command — set a temperature_fan's target temperature.
+- **Available when:** Always
+- **Parameters:** `TEMPERATURE_FAN`, `TARGET`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `TURN_OFF_HEATERS` · G-code (via `printer.gcode.script`)
+Klipper `TURN_OFF_HEATERS` command from the official G-Code reference.
+- **Available when:** any of `extruder`, `heater_bed` present
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Tuning
+
+#### `M220` · G-code (via `printer.gcode.script`)
+Set speed factor override.
+- **Available when:** Klipper object `gcode_move` present
+- **Parameters:** `S`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+#### `M221` · G-code (via `printer.gcode.script`)
+Set extrude factor override.
+- **Available when:** Klipper object `gcode_move` present
+- **Parameters:** `S`
+- **Reference:** https://www.klipper3d.org/G-Codes.html
+
+### Webcam
+
+#### `server.webcams.list` · Moonraker JSON-RPC
+List configured webcams.
+- **Available when:** Always
+- **Reference:** https://moonraker.readthedocs.io/en/latest/external_api/webcams/