Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) ·
Expand Down
17 changes: 17 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions app/src/debug/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
DEBUG-only resource overrides. The debug build installs as a side-by-side package
(applicationId works.mees.jiib.dev — see app/build.gradle.kts) so it must be visually
distinguishable from the live app on the launcher. Only the label changes; the launcher
icon is intentionally shared (icon changes require owner sign-off).
-->
<resources>
<string name="app_name">jiib dev</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
28 changes: 26 additions & 2 deletions app/src/main/java/works/mees/jiib/di/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion app/src/main/java/works/mees/jiib/state/PrinterState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
50 changes: 47 additions & 3 deletions app/src/main/java/works/mees/jiib/state/PrinterStateReducer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions app/src/main/java/works/mees/jiib/ui/extrude/ExtrudeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = buildList {
if (heatersLine.isNotBlank()) add(heatersLine)
add(jobLine)
Expand Down
Loading
Loading