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/