bambu-rs 0.1.0

AI-agent-friendly Bambu Lab 3D printer CLI & library
Documentation
import { useState } from "react";
import type { CSSProperties } from "react";
import type { TempPoint } from "../useStatus";
import type { AmsTray } from "../types";
import { remainText, swatch, trayLabel, wifiTier } from "../format";

/// The plate preview embedded in a sliced .3mf; renders nothing if absent or for
/// non-.3mf files.
export function Thumb({ file, className }: { file: string; className?: string }) {
  const [ok, setOk] = useState(true);
  if (!ok || !file.toLowerCase().endsWith(".3mf")) return null;
  const name = file.startsWith("/") ? file : `/${file}`;
  return (
    <img
      className={className ?? "thumb"}
      loading="lazy"
      alt=""
      src={`/api/file/thumbnail?name=${encodeURIComponent(name)}`}
      onError={() => setOk(false)}
      data-testid="thumb"
    />
  );
}

export function Field({ label, value, big }: { label: string; value: string; big?: boolean }) {
  return (
    <div className="field">
      <span className="lbl">{label}</span>
      <span className={`val${big ? " val--big" : ""}`} data-testid={`f-${label}`}>
        {value}
      </span>
    </div>
  );
}

export function Bar({ pct, prep, running }: { pct: number; prep?: boolean; running?: boolean }) {
  const tone = prep ? " bar__fill--prep" : running ? " bar__fill--running" : "";
  return (
    <div className="bar">
      <div
        className={`bar__fill${tone}`}
        style={{ width: `${Math.max(0, Math.min(100, pct))}%` }}
        data-testid="bar-fill"
      />
    </div>
  );
}

// The AMS reports a coarse 1–5 level on the wire field `humidity`, but a HIGHER
// number means DRIER (verified against this device: level 5 with raw 0 = dry; the
// upstream field name reads backwards). Render it as a 5-segment dryness meter
// (filled from the humid end up to the level) — a discrete readout gauge, NOT a
// track-and-thumb, which read as an operable slider. The filled count + the
// humid/dry end labels carry level and direction without colour (CUD); colour
// just reinforces (more filled = drier; dry end green, humid end red). The exact
// verdict word (dry/good/fair/damp/wet) stays in the hover/aria title.
const DRY_WORD: Record<number, string> = { 5: "dry", 4: "good", 3: "fair", 2: "damp", 1: "wet" };

export function Humidity({ level, raw }: { level: number; raw?: number | null }) {
  const word = DRY_WORD[level] ?? `${level}/5`;
  const tone = level >= 4 ? "ok" : level === 3 ? "warn" : "err";
  const lvl = Math.min(5, Math.max(1, level));
  return (
    <span
      className={`hum hum--${tone}`}
      aria-label={`humidity ${level} of 5 (${word})`}
      title={`humidity ${level}/5 (${word})${raw != null ? ` · raw ${raw}` : ""} — higher is drier`}
    >
      <span className="hum__end">humid</span>
      <span className="hum__meter" aria-hidden="true">
        {[1, 2, 3, 4, 5].map((i) => (
          <span key={i} className={`hum__seg${i <= lvl ? " is-on" : ""}`} />
        ))}
      </span>
      <span className="hum__end">dry</span>
    </span>
  );
}

// Wi-Fi signal as a 4-bar meter: the filled bar count, the tone colour, and the
// dBm text all carry the strength, so it reads without relying on colour (CUD,
// same philosophy as Humidity). Renders nothing when the signal is absent or
// unparseable (the `<redacted>` sentinel is already None on the wire).
export function WifiSignal({ signal }: { signal: string | null }) {
  const t = wifiTier(signal);
  if (!t) return null;
  return (
    <span
      className={`wifi wifi--${t.tone}`}
      aria-label={`wifi ${t.dbm}dBm (${t.word})`}
      title={`wifi ${t.dbm}dBm — ${t.word}`}
      data-testid="wifi"
    >
      <span className="wifi__bars" aria-hidden="true">
        {[1, 2, 3, 4].map((i) => (
          <span key={i} className={`wifi__bar${i <= t.bars ? " is-on" : ""}`} />
        ))}
      </span>
      <span className="wifi__v">{t.dbm}dBm</span>
    </span>
  );
}

export function TempReadout({
  label,
  cur,
  target,
  accent,
}: {
  label: string;
  cur?: number | null;
  target?: number | null;
  accent?: boolean;
}) {
  return (
    <div className="tr">
      <span className="lbl">{label}</span>
      <span className={`tr__v${accent ? " tr__v--accent" : ""}`} data-testid={`${label}-temp`}>
        {cur != null ? cur.toFixed(1) : "—"}
        <span className="tr__u">°C</span>
      </span>
      <span className="tr__t">{target ? `▏ target ${Math.round(target)}°` : "off"}</span>
    </div>
  );
}

export function Tray({ t, ext }: { t: AmsTray; ext?: boolean }) {
  const color = swatch(t.color, t.cols);
  // State is never colour-only: the active spool also widens its ring + shows a
  // "loaded" marker; the swap target gets a dashed accent ring.
  const target = t.is_target && !t.is_active;
  const cls = [
    "spool",
    ext ? "spool--ext" : "",
    t.is_active ? "spool--active" : "",
    target ? "spool--target" : "",
    color ? "" : "spool--empty",
  ]
    .filter(Boolean)
    .join(" ");
  const temps =
    t.nozzle_temp_min != null && t.nozzle_temp_max != null
      ? `${t.nozzle_temp_min}–${t.nozzle_temp_max}°C`
      : null;
  const title = [t.name ?? t.material ?? undefined, temps].filter(Boolean).join(" · ") || undefined;
  // 1-based to match the number printed on the AMS; data-testid keeps the raw
  // 0-based wire id so tests/automation address trays by the protocol value.
  const id = ext ? "EXT" : trayLabel(t.id);
  const rem = remainText(t.remain);
  return (
    <div className={cls} data-testid={`tray-${ext ? "ext" : t.id}`} title={title}>
      <span
        className="spool__disc"
        // The filament colour is the hero; the hub punches a hole using the panel
        // background so the ring reads as a wound spool seen face-on.
        style={color ? ({ "--fil": color } as CSSProperties) : undefined}
        aria-hidden
      >
        {t.is_active && <i className="spool__load" aria-hidden />}
      </span>
      <span className="spool__id">
        {id}
        <span className="spool__mat"> · {t.material ?? "—"}</span>
      </span>
      {rem !== "—" ? <span className="spool__rem">{rem}</span> : null}
    </div>
  );
}

// One series per chart: the nozzle and bed each get their own value+graph tier,
// so the section reads by meaning (per metric), not "values" then "a graph".
export function Sparkline({ history, metric }: { history: TempPoint[]; metric: "nozzle" | "bed" }) {
  const W = 600;
  const H = 40;
  const pad = 3;
  const sel = (p: TempPoint) => (metric === "nozzle" ? p.nozzle : p.bed);
  const vals = history.map(sel).filter((v): v is number => v != null);
  if (history.length < 2 || vals.length === 0) {
    return <div className="spark spark--empty" data-testid={`spark-${metric}`} />;
  }
  const min = Math.min(...vals);
  const max = Math.max(...vals);
  const range = Math.max(1, max - min);
  const x = (i: number) => pad + (i / (history.length - 1)) * (W - 2 * pad);
  const y = (v: number) => pad + (1 - (v - min) / range) * (H - 2 * pad);
  const points = history
    .map((p, i) => {
      const v = sel(p);
      return v == null ? null : `${x(i).toFixed(1)},${y(v).toFixed(1)}`;
    })
    .filter(Boolean)
    .join(" ");
  return (
    <svg
      className="spark"
      viewBox={`0 0 ${W} ${H}`}
      preserveAspectRatio="none"
      data-testid={`spark-${metric}`}
    >
      <polyline className={`spark__${metric}`} points={points} />
    </svg>
  );
}