mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
449ffcd62d
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
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
/**
|
|
* 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<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_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<BambuddyOptions, "materials" | "minGrams"> = {}): 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<string, string> = { 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 } };
|
|
},
|
|
};
|
|
}
|