Bambuddy sync: match the real spools API

Checked against Greg's Bambuddy: the key goes in X-API-Key, colors are
color_name (often null on RFID spools) and rgba, grams left is
label_weight minus weight_used, and archived spools carry archived_at.
Unnamed spools get the nearest plain color name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
Claude
2026-10-12 02:47:48 +00:00
parent bd97720be1
commit 449ffcd62d
6 changed files with 84 additions and 71 deletions
+48 -35
View File
@@ -1,13 +1,8 @@
/**
* Bambuddy: filament colors in stock, for the Store's Filament options.
* Hourly. Read only.
*
* UNVERIFIED: Bambuddy's API is not documented anywhere this project has, and
* no request has been made to a real Bambuddy. The endpoint path is a setting
* (BAMBUDDY_SPOOLS_PATH) and everything that depends on the response's shape
* is in `mapBambuddySpools` below, so fixing it against the real API is a
* change to one function. Check it against a live Bambuddy before relying on
* it (docs/content-service.md, "Sync jobs").
* Hourly. Read only. GET /api/v1/inventory/spools with an API key (made in
* Bambuddy's Settings) in the X-API-Key header. The field names below were
* checked against Greg's Bambuddy on 2026-10-12.
*/
import type { SyncJob } from "../types.ts";
import { mergeOptions, normalizeHex, storeOnLines, type StockOption } from "./stock.ts";
@@ -18,7 +13,7 @@ export interface BambuddyOptions {
url: string;
/** Path of the spool or filament inventory endpoint. */
path?: string;
/** Sent as `Authorization: Bearer`, or in `authHeader` when that is set. */
/** Sent in the X-API-Key header, or in `authHeader` when that is set. */
apiKey?: string;
authHeader?: string;
/** Option group the colors feed. Default Filament. */
@@ -39,40 +34,58 @@ export interface Spool {
remaining?: number;
}
const pick = (o: Record<string, unknown>, keys: string[]) => {
for (const k of keys) {
const v = o[k];
if (v !== undefined && v !== null && v !== "") return v;
/** Plain names for spools Bambuddy leaves unnamed (RFID spools have no color_name). */
const BASIC_COLORS: [string, number, number, number][] = [
["Black", 0, 0, 0],
["White", 255, 255, 255],
["Gray", 128, 128, 128],
["Silver", 192, 192, 192],
["Red", 200, 30, 40],
["Orange", 255, 120, 0],
["Yellow", 255, 210, 0],
["Green", 0, 160, 70],
["Blue", 20, 90, 200],
["Purple", 120, 40, 160],
["Pink", 240, 110, 170],
["Brown", 110, 70, 40],
["Beige", 240, 225, 190],
];
/** The nearest of BASIC_COLORS to a #rrggbb hex. */
export function basicColorName(hex: string): string {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
let best = BASIC_COLORS[0]!;
let bestD = Infinity;
for (const c of BASIC_COLORS) {
const d = (c[1] - r) ** 2 + (c[2] - g) ** 2 + (c[3] - b) ** 2;
if (d < bestD) [best, bestD] = [c, d];
}
return undefined;
};
return best[0];
}
/**
* THE ONE PLACE that knows Bambuddy's response shape (a guess; see above).
* Accepts a list, or an object holding one under items, spools, data or
* results, and reads each spool's color name, hex, material and grams left
* under the common spellings. Archived or empty-named entries are skipped.
* THE ONE PLACE that knows Bambuddy's spool shape. The response is a list (or
* an object holding one under items). Each spool has material, color_name
* (often null), rgba like "000000FF", label_weight (grams of filament when
* full), weight_used and archived_at. A spool with no color_name is named
* from its color (Black, Blue...); a color name set in Bambuddy wins.
*/
export function mapBambuddySpools(json: unknown): Spool[] {
const root = json as Record<string, unknown> | unknown[];
const list = Array.isArray(root)
? root
: (["items", "spools", "data", "results"].map((k) => (root as Record<string, unknown>)?.[k]).find(Array.isArray) as unknown[] | undefined);
if (!list) throw new Error("Bambuddy answered with no list of spools");
const list = Array.isArray(json) ? json : (json as Record<string, unknown> | null)?.items;
if (!Array.isArray(list)) throw new Error("Bambuddy answered with no list of spools");
const out: Spool[] = [];
for (const raw of list) {
if (!raw || typeof raw !== "object") continue;
const o = raw as Record<string, unknown>;
if (o.archived === true || o.deleted === true) continue;
const name = pick(o, ["color_name", "colorName", "color", "name"]);
if (typeof name !== "string" || !name.trim()) continue;
const s: Spool = { name: name.trim() };
const hex = normalizeHex(pick(o, ["color_hex", "colorHex", "hex", "rgba", "color_code"]));
if (o.archived_at) continue;
const hex = normalizeHex(o.rgba);
const named = typeof o.color_name === "string" && o.color_name.trim() ? o.color_name.trim() : undefined;
const name = named ?? (hex ? basicColorName(hex) : undefined);
if (!name) continue;
const s: Spool = { name };
if (hex) s.hex = hex;
const material = pick(o, ["material", "filament_type", "type"]);
if (typeof material === "string") s.material = material.trim();
const grams = Number(pick(o, ["remaining_weight", "remainingWeight", "weight_remaining", "remaining", "remaining_grams"]));
if (Number.isFinite(grams)) s.remaining = grams;
if (typeof o.material === "string") s.material = o.material.trim();
if (typeof o.label_weight === "number") s.remaining = o.label_weight - (typeof o.weight_used === "number" ? o.weight_used : 0);
out.push(s);
}
return out;
@@ -97,10 +110,10 @@ export function bambuddyJob(o: BambuddyOptions): SyncJob {
healthcheckUrl: o.healthcheckUrl,
async run(ctx) {
const headers: Record<string, string> = { accept: "application/json" };
if (o.apiKey) headers[o.authHeader?.toLowerCase() || "authorization"] = o.authHeader ? o.apiKey : `Bearer ${o.apiKey}`;
if (o.apiKey) headers[(o.authHeader || "x-api-key").toLowerCase()] = o.apiKey;
const url = `${o.url.replace(/\/$/, "")}/${(o.path ?? BAMBUDDY_DEFAULT_PATH).replace(/^\//, "")}`;
const res = await ctx.fetch(url, { headers });
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY`);
if (res.status === 401 || res.status === 403) throw new Error(`Bambuddy refused the request (${res.status}); check BAMBUDDY_API_KEY (made in Bambuddy's Settings)`);
if (!res.ok) throw new Error(`Bambuddy answered ${res.status}; check BAMBUDDY_SPOOLS_PATH`);
const options = filamentOptions(mapBambuddySpools(await res.json()), o);
const lines = storeOnLines(ctx, "filamentStock", [{ group, options }]);