/** * Bambuddy: filament colors in stock, for the Store's Filament options. * 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"; export const BAMBUDDY_DEFAULT_PATH = "/api/v1/inventory/spools"; export interface BambuddyOptions { url: string; /** Path of the spool or filament inventory endpoint. */ path?: string; /** 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. */ group?: string; /** Only these materials (like PLA, PETG), when set. */ materials?: string[]; /** A spool with less filament than this, in grams, counts as out. Default 50. */ minGrams?: number; everyMinutes?: number; healthcheckUrl?: string; } export interface Spool { name: string; hex?: string; material?: string; /** Grams left, when the source says. */ remaining?: number; } /** 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 best[0]; } /** * 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 list = Array.isArray(json) ? json : (json as Record | 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; 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; 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; } /** Spools as Filament options: in stock when some spool of that color has enough left. */ export function filamentOptions(spools: Spool[], o: Pick = {}): StockOption[] { const materials = o.materials?.length ? new Set(o.materials.map((m) => m.toLowerCase())) : null; const min = o.minGrams ?? 50; return mergeOptions( spools .filter((s) => !materials || (s.material && materials.has(s.material.toLowerCase()))) .map((s) => ({ name: s.name, ...(s.hex ? { hex: s.hex } : {}), inStock: s.remaining === undefined || s.remaining >= min })), ); } export function bambuddyJob(o: BambuddyOptions): SyncJob { const group = o.group ?? "Filament"; return { name: "bambuddy", everyMinutes: o.everyMinutes ?? 60, healthcheckUrl: o.healthcheckUrl, async run(ctx) { const headers: Record = { accept: "application/json" }; 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 (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 }]); const inStock = options.filter((x) => x.inStock).length; return { message: `${inStock} of ${options.length} colors in stock, on ${lines} lines`, counts: { colors: options.length, inStock, lines } }; }, }; }