bambu-rs 0.1.0

AI-agent-friendly Bambu Lab 3D printer CLI & library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
import { useState, type KeyboardEvent } from "react";
import type { Control, Axis, CalibrateOpts } from "../useControl";
import type { PrinterStatus } from "../types";
import { WifiSignal } from "./widgets";

// Motion is unsafe mid-job: jog/home/calibrate are gated on these phases.
const BUSY_STATES = new Set(["RUNNING", "PAUSE", "PREPARE", "SLICING"]);
function isBusy(state: string | null): boolean {
  return BUSY_STATES.has((state ?? "").toUpperCase());
}

// Jog feedrates (mm/min): XY moves fast, Z is geared slower for safety.
const FEED_XY = 3000;
const FEED_Z = 600;
const FEED_EXTRUDE = 300;
const MIN_EXTRUDE_TEMP = 170; // cold extrusion guard (°C)

// Z jog rungs: magnitude grows away from the centre datum (.1 nearest, 10 at the
// ends), mirroring the dial's rings (inner = fine, outer = coarse). Tapping a rung
// jogs Z by that signed step — magnitude is the position, just like the dial.
const Z_RUNGS = [
  { step: 10, label: "10", ri: 2 },
  { step: 1, label: "1", ri: 1 },
  { step: 0.1, label: ".1", ri: 0 },
] as const;
const EXTRUDE_LENS = [1, 5, 10] as const;

// ── Concentric XY jog dial ──────────────────────────────────────────────────
// A polar control: four 90° wedges (Y+ top, X+ right, Y- bottom, X- left), each
// split into three concentric rings = step (0.1 / 1 / 10 mm). Tapping a wedge's ring
// jogs that axis by that step; the centre is home. Replaces the D-pad + the separate
// step selector — direction is the wedge, magnitude is the ring.
const JOG_RINGS = [
  { step: 0.1, label: ".1", r0: 11.5, r1: 20 },
  { step: 1, label: "1", r0: 20, r1: 32 },
  { step: 10, label: "10", r0: 32, r1: 44.5 },
] as const;
const JOG_DIRS = [
  { axis: "y" as Axis, dir: 1, label: "Y+", key: "yplus", ang: -90 },
  { axis: "x" as Axis, dir: 1, label: "X+", key: "xplus", ang: 0 },
  { axis: "y" as Axis, dir: -1, label: "Y−", key: "yminus", ang: 90 },
  { axis: "x" as Axis, dir: -1, label: "X−", key: "xminus", ang: 180 },
] as const;

// Polar → cartesian about the (50,50) viewBox centre.
function polar(r: number, deg: number): [number, number] {
  const a = (deg * Math.PI) / 180;
  return [50 + r * Math.cos(a), 50 + r * Math.sin(a)];
}
// SVG path for an annular sector (ring band r0..r1 between angles a0..a1, <180°).
function ringSector(r0: number, r1: number, a0: number, a1: number): string {
  const [x0o, y0o] = polar(r1, a0);
  const [x1o, y1o] = polar(r1, a1);
  const [x1i, y1i] = polar(r0, a1);
  const [x0i, y0i] = polar(r0, a0);
  return `M ${x0o} ${y0o} A ${r1} ${r1} 0 0 1 ${x1o} ${y1o} L ${x1i} ${y1i} A ${r0} ${r0} 0 0 0 ${x0i} ${y0i} Z`;
}

function JogDial({
  onJog,
  onHome,
  disabled,
  title,
}: {
  onJog: (axis: Axis, mm: number) => void;
  onHome: () => void;
  disabled: boolean;
  title?: string;
}) {
  const HALF = 44; // wedge half-angle; a hair under 45° leaves a thin seam between wedges
  // The segments are SVG, so they aren't <button>s — wire up keyboard activation
  // (Enter/Space) and guard every action on `disabled`, so keyboard/switch users can
  // jog and home just like the old button D-pad. tabIndex below removes them from the
  // tab order while disabled; the click guard backstops the CSS pointer-events:none.
  const act = (fn: () => void) => {
    if (!disabled) fn();
  };
  const onKey = (e: KeyboardEvent<SVGElement>, fn: () => void) => {
    if (disabled) return;
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      fn();
    }
  };
  return (
    // viewBox padded beyond the 100-unit dial so the axis labels (r=49) sit clearly
    // outside the outer ring instead of grazing it; the .dial CSS width compensates.
    <svg
      className={disabled ? "dial dial--off" : "dial"}
      viewBox="-4 -4 108 108"
      role="group"
      aria-label="XY jog"
      aria-disabled={disabled}
      data-testid="jog-dial"
    >
      {title && <title>{title}</title>}
      {JOG_DIRS.flatMap((d) =>
        JOG_RINGS.map((ring, ri) => {
          const [tx, ty] = polar((ring.r0 + ring.r1) / 2, d.ang);
          return (
            <g key={`${d.key}-${ring.label}`} className={`dial__seg dial__seg--r${ri}`}>
              <path
                d={ringSector(ring.r0, ring.r1, d.ang - HALF, d.ang + HALF)}
                role="button"
                tabIndex={disabled ? -1 : 0}
                aria-disabled={disabled || undefined}
                data-testid={`jog-${d.key}-${ring.label}`}
                aria-label={`${d.label} ${ring.step} mm`}
                onClick={() => act(() => onJog(d.axis, d.dir * ring.step))}
                onKeyDown={(e) => onKey(e, () => onJog(d.axis, d.dir * ring.step))}
              />
              <text className="dial__num" x={tx} y={ty}>
                {ring.label}
              </text>
            </g>
          );
        }),
      )}
      {JOG_DIRS.map((d) => {
        const [lx, ly] = polar(49, d.ang);
        return (
          <text key={d.key} className="dial__axis" x={lx} y={ly}>
            {d.label}
          </text>
        );
      })}
      <circle
        className="dial__home"
        cx="50"
        cy="50"
        r="11"
        role="button"
        tabIndex={disabled ? -1 : 0}
        aria-disabled={disabled || undefined}
        data-testid="home-all"
        aria-label="home all axes (G28)"
        onClick={() => act(() => onHome())}
        onKeyDown={(e) => onKey(e, () => onHome())}
      />
      <text className="dial__home-glyph" x="50" y="50">
        ⌂
      </text>
    </svg>
  );
}

// Z jog: a vertical ladder echoing the dial. Z+ rungs stack above the centre datum,
// Z- below; magnitude grows outward (coarse 10 mm at the ends), so the same "further
// out = bigger move" reading carries over from the XY dial — no separate step picker.
function JogZStack({
  onJog,
  disabled,
  title,
}: {
  onJog: (mm: number) => void;
  disabled: boolean;
  title?: string;
}) {
  const rung = (sign: 1 | -1, z: (typeof Z_RUNGS)[number]) => (
    <button
      key={`${sign > 0 ? "zplus" : "zminus"}-${z.label}`}
      className={`zrung zrung--r${z.ri}`}
      disabled={disabled}
      title={title}
      data-testid={`jog-${sign > 0 ? "zplus" : "zminus"}-${z.label}`}
      aria-label={`Z${sign > 0 ? "+" : "−"} ${z.step} mm`}
      onClick={() => onJog(sign * z.step)}
    >
      <span className="zrung__dir">{sign > 0 ? "↑" : "↓"}</span>
      <span className="zrung__mag">{z.label}</span>
    </button>
  );
  return (
    <div className="zstack" role="group" aria-label="Z jog" data-testid="jog-zstack">
      {/* Z+ : coarse (10) at the top, fine (.1) nearest the datum. */}
      {Z_RUNGS.map((z) => rung(1, z))}
      <span className="zstack__datum lbl">Z</span>
      {/* Z- : fine (.1) nearest the datum, coarse (10) at the bottom (Z_RUNGS reversed). */}
      {[...Z_RUNGS].reverse().map((z) => rung(-1, z))}
    </div>
  );
}

export function MachineSection({ s, control }: { s: PrinterStatus; control: Control }) {
  const b = control.busy;
  const busy = isBusy(s.gcode_state);
  const stateName = s.gcode_state ?? "unknown";
  // Both jog controls encode magnitude in position: the dial's rings (XY) and the
  // Z ladder's rungs — so there's no separate step state to track.
  const [extLen, setExtLen] = useState<number>(5);
  const [calOpen, setCalOpen] = useState(false);

  // A jog/home is unavailable if a write is in flight OR the printer is busy.
  const motionDisabled = !!b || busy;
  const motionTitle = busy ? `unavailable while ${stateName}` : undefined;

  // Temp the AMS unload heats to: honour an explicit nozzle setpoint, else a
  // default that softens both PLA and PETG enough to retract cleanly.
  const amsUnloadTemp = s.nozzle_target && s.nozzle_target > 0 ? s.nozzle_target : 240;
  const nozzleCold = (s.nozzle_temper ?? 0) < MIN_EXTRUDE_TEMP;
  const extrudeDisabled = !!b || busy || nozzleCold;
  const extrudeTitle = busy
    ? `unavailable while ${stateName}`
    : nozzleCold
      ? "nozzle must be ≥170°C"
      : undefined;

  return (
    <div className="cfold" data-testid="machine">
      {/* ── MOVE: concentric XY jog dial + Z column ─────────────────────────── */}
      <div className="msub">
        <div className="lbl">move</div>
        <div className="jog">
          {/* XY: a radial dial — direction is the wedge, step (0.1/1/10mm) is the ring. */}
          <JogDial
            onJog={(axis, mm) => void control.jog(axis, mm, FEED_XY)}
            onHome={() => void control.home("all")}
            disabled={motionDisabled}
            title={motionTitle}
          />
          {/* Z ladder — the gantry, beside the bed; magnitude grows outward like the dial. */}
          <JogZStack
            onJog={(mm) => void control.jog("z", mm, FEED_Z)}
            disabled={motionDisabled}
            title={motionTitle}
          />
        </div>
      </div>

      {/* ── FILAMENT (extrude) ───────────────────────────────────────────── */}
      <div className="msub">
        <div className="lbl">filament</div>
        <div className="seg" role="radiogroup" aria-label="extrude length (mm)">
          {EXTRUDE_LENS.map((mm) => {
            const active = mm === extLen;
            return (
              <button
                key={mm}
                className={`btn btn--sm seg__opt${active ? " is-active" : ""}`}
                role="radio"
                aria-checked={active}
                data-testid="extrude-length"
                onClick={() => setExtLen(mm)}
              >
                {mm} mm
              </button>
            );
          })}
        </div>
        <div className="btns">
          <button
            className="btn btn--sm"
            disabled={extrudeDisabled}
            title={extrudeTitle}
            data-testid="extrude-load"
            onClick={() => void control.extrude(extLen, FEED_EXTRUDE)}
          >
            load
          </button>
          <button
            className="btn btn--sm"
            disabled={extrudeDisabled}
            title={extrudeTitle}
            data-testid="extrude-unload"
            onClick={() => void control.extrude(-extLen, FEED_EXTRUDE)}
          >
            unload
          </button>
        </div>
        {/* AMS-coordinated unload: pulls filament the whole way back to the spool
            (the extruder-only retract above just nudges it at the nozzle). The
            firmware heats to the target itself, so it works from a cold nozzle —
            unlike the extrude buttons, only the busy gate applies. */}
        <div className="btns">
          <button
            className="btn btn--sm"
            disabled={!!b || busy}
            title={busy ? `unavailable while ${stateName}` : "AMS unload (whole path)"}
            data-testid="ams-unload"
            onClick={() => control.amsChange(255, amsUnloadTemp)}
          >
            AMS unload
          </button>
        </div>
      </div>

      {/* ── MAINTENANCE ──────────────────────────────────────────────────── */}
      <div className="msub">
        <div className="lbl">maintenance</div>
        {/* Hardware/connectivity status you check during upkeep: the installed nozzle
            spec and the WiFi signal. Each renders only when its data is present. */}
        {(s.nozzle_diameter || s.wifi_signal) && (
          <div className="mstat" data-testid="machine-hw">
            {s.nozzle_diameter && (
              <span className="jobspec" data-testid="nozzle-spec" title="installed nozzle">
                Ø{s.nozzle_diameter}
                {s.nozzle_type ? ` ${s.nozzle_type.replace(/_/g, " ")}` : ""}
              </span>
            )}
            <WifiSignal signal={s.wifi_signal} />
          </div>
        )}
        {/* Everyday upkeep: AMS state + motor release. */}
        <div className="btns">
          <button
            className="btn btn--sm"
            disabled={!!b}
            data-testid="ams-resume"
            onClick={() => void control.ams("resume", false)}
          >
            ams resume
          </button>
          <button
            className="btn btn--sm"
            disabled={!!b}
            data-testid="ams-reset"
            onClick={() => control.ams("reset", true)}
          >
            ams reset
          </button>
          <button
            className="btn btn--sm"
            disabled={!!b}
            data-testid="steppers"
            onClick={() => control.steppers()}
          >
            disable steppers
          </button>
        </div>
        {/* Heavier whole-machine routines, set apart on their own tier. Opening the
            calibration picker is always allowed (configuration); its run button gates on idle. */}
        <div className="btns btns--sep">
          <button
            className="btn btn--sm"
            disabled={!!b}
            title="choose calibration routines"
            data-testid="calibrate-open"
            onClick={() => setCalOpen(true)}
          >
            calibrate…
          </button>
          <button
            className="btn btn--sm btn--danger"
            disabled={!!b}
            data-testid="reboot"
            onClick={() => control.reboot()}
          >
            reboot
          </button>
        </div>
      </div>
      {calOpen && (
        <CalibrateModal
          control={control}
          busy={!!b || busy}
          stateName={stateName}
          onClose={() => setCalOpen(false)}
        />
      )}
    </div>
  );
}

// The calibration picker: opened from "calibrate…", it lists the printer's routines so the
// operator chooses which to run (ALL on by default — the common "full calibration" case).
// This modal IS the confirmation gate (it warns that the printer moves), so "run" posts
// directly. Disabled/`busy` reflects whether motion is currently allowed.
const CAL_ROUTINES = [
  { key: "bed_level", label: "bed level", desc: "auto bed leveling (mesh)" },
  { key: "vibration", label: "vibration", desc: "resonance / vibration compensation" },
  { key: "motor_noise", label: "motor noise", desc: "motor noise cancellation" },
] as const;

function CalibrateModal({
  control,
  busy,
  stateName,
  onClose,
}: {
  control: Control;
  busy: boolean;
  stateName: string;
  onClose: () => void;
}) {
  const [cal, setCal] = useState<CalibrateOpts>({
    bed_level: true,
    vibration: true,
    motor_noise: true,
  });
  const [running, setRunning] = useState(false);
  const anyCal = cal.bed_level || cal.vibration || cal.motor_noise;
  const run = async () => {
    setRunning(true);
    try {
      await control.calibrate(cal);
      onClose();
    } finally {
      setRunning(false);
    }
  };
  return (
    <div
      className="modal"
      role="dialog"
      aria-modal="true"
      data-testid="calibrate-modal"
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div className="modal__box">
        <div className="cam__modal-head">
          <span className="lbl">calibration</span>
          <button className="cam__manage" data-testid="calibrate-close" onClick={onClose}>
            close
          </button>
        </div>
        <p className="dim cal__intro">
          Pick the routines to run — all are on by default; turn off any you want to skip.
        </p>
        <div className="cal__list">
          {CAL_ROUTINES.map((t) => (
            <label key={t.key} className="cal__item">
              <input
                type="checkbox"
                checked={cal[t.key]}
                disabled={running}
                data-testid={`cal-${t.key}`}
                onChange={(e) => setCal((c) => ({ ...c, [t.key]: e.target.checked }))}
              />
              <span className="cal__item-text">
                <span className="cal__item-name">{t.label}</span>
                <span className="dim cal__item-desc">{t.desc}</span>
              </span>
            </label>
          ))}
        </div>
        <p className="cal__warn" data-testid="calibrate-warn">
          ⚠ the printer will move on its own — keep the bed clear.
        </p>
        <div className="btns">
          <button className="btn" onClick={onClose} disabled={running}>
            cancel
          </button>
          <button
            className="btn btn--go"
            data-testid="calibrate-run"
            disabled={busy || running || !anyCal}
            title={
              busy
                ? `unavailable while ${stateName}`
                : anyCal
                  ? undefined
                  : "select at least one routine"
            }
            onClick={() => void run()}
          >
            {running ? "calibrating…" : "run calibration"}
          </button>
        </div>
      </div>
    </div>
  );
}