mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-07 19:58:24 +00:00
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:
@@ -43,10 +43,11 @@ SUWAYOMI_PASS=
|
||||
# PersonalReader, light novels and books (daily).
|
||||
KAVITA_URL=
|
||||
KAVITA_API_KEY=
|
||||
# Filament in stock (hourly). UNVERIFIED API: check the path and mapping first.
|
||||
# Filament in stock (hourly).
|
||||
BAMBUDDY_URL=
|
||||
# BAMBUDDY_SPOOLS_PATH=/api/v1/inventory/spools
|
||||
# BAMBUDDY_API_KEY=
|
||||
# API key from Bambuddy's Settings, sent in X-API-Key
|
||||
BAMBUDDY_API_KEY=
|
||||
# BAMBUDDY_AUTH_HEADER=
|
||||
# BAMBUDDY_GROUP=Filament
|
||||
# BAMBUDDY_MATERIALS=PLA,PETG
|
||||
|
||||
@@ -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 }]);
|
||||
|
||||
@@ -17,6 +17,6 @@ and ids, names and image addresses are illustrative.
|
||||
| `anilist-page.sample.json` | `POST https://graphql.anilist.co` with the batched `Page { media(id_in:) }` query |
|
||||
| `kavita-series.sample.json` | `POST <KAVITA_URL>/api/Series/all-v2` (SeriesDto list) |
|
||||
| `kavita-metadata.sample.json` | `GET <KAVITA_URL>/api/Series/metadata?seriesId=`, one answer per series id |
|
||||
| `bambuddy-spools.sample.json` | **A guess.** Bambuddy's API is not documented anywhere this project has; this only exercises `mapBambuddySpools`' tolerant reading. |
|
||||
| `bambuddy-spools.sample.json` | GET `/api/v1/inventory/spools`, fields copied from one spool on Greg's Bambuddy (2026-10-12); the other spools are made up. |
|
||||
| `homebox-labels.sample.json` | `GET <HOMEBOX_URL>/api/v1/labels` |
|
||||
| `homebox-items.sample.json` | `GET <HOMEBOX_URL>/api/v1/items?labels=…` |
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"_comment": "GUESSED SHAPE. Bambuddy's API is undocumented here; this only exercises mapBambuddySpools' tolerant reading.",
|
||||
"items": [
|
||||
{ "id": 1, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DCFF", "remaining_weight": 640 },
|
||||
{ "id": 2, "material": "PLA", "color_name": "Jade White", "color_hex": "F5F5DC", "remaining_weight": 20 },
|
||||
{ "id": 3, "material": "PLA", "colorName": "Bambu Green", "colorHex": "#00AE42", "remainingWeight": 15 },
|
||||
{ "id": 4, "material": "PETG", "color": "Translucent Orange", "hex": "#FF6A13", "remaining": 900 },
|
||||
{ "id": 5, "material": "PLA", "color_name": "Old Spool", "archived": true, "remaining_weight": 500 },
|
||||
{ "id": 6, "material": "PLA", "color_name": "Charcoal", "color_hex": "#2b2b2b" }
|
||||
]
|
||||
}
|
||||
[
|
||||
{ "id": 20, "material": "PLA", "subtype": "Tough+", "color_name": null, "rgba": "000000FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 6.07, "archived_at": null },
|
||||
{ "id": 21, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 360, "archived_at": null },
|
||||
{ "id": 22, "material": "PLA", "subtype": "Basic", "color_name": "Jade White", "rgba": "F5F5DCFF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 980, "archived_at": null },
|
||||
{ "id": 23, "material": "PLA", "subtype": "Basic", "color_name": "Bambu Green", "rgba": "00AE42FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 985, "archived_at": null },
|
||||
{ "id": 24, "material": "PETG", "subtype": "Translucent", "color_name": "Translucent Orange", "rgba": "FF6A13FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 100, "archived_at": null },
|
||||
{ "id": 25, "material": "PLA", "subtype": "Basic", "color_name": "Old Spool", "rgba": "808080FF", "brand": "Bambu Lab", "label_weight": 1000, "core_weight": 216, "weight_used": 0, "archived_at": "2026-09-01T00:00:00" }
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The homelab jobs (Suwayomi, Kavita, Bambuddy, Homebox) and Library
|
||||
* suggestions, against the hand-written sample payloads in ./fixtures. Those
|
||||
* are documented shapes (Bambuddy's is a guess), never live responses; see
|
||||
* are documented shapes, never live responses; see
|
||||
* fixtures/README.md.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { parseTokens } from "../../src/config.ts";
|
||||
import { createHttpServer } from "../../src/http.ts";
|
||||
import { listSuggestions } from "../../src/suggestions.ts";
|
||||
import { bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
|
||||
import { basicColorName, bambuddyJob, filamentOptions, mapBambuddySpools } from "../../src/sync/jobs/bambuddy.ts";
|
||||
import { homelabSettings } from "../../src/sync/jobs/homelab.ts";
|
||||
import { homeboxJob, materialOptions, parseFilters, parseItems } from "../../src/sync/jobs/homebox.ts";
|
||||
import { kavitaJob, kavitaState, parseMetadata, parseSeries } from "../../src/sync/jobs/kavita.ts";
|
||||
@@ -284,18 +284,20 @@ describe("Kavita", () => {
|
||||
|
||||
// ---------------------------------------------------------------- Bambuddy
|
||||
|
||||
describe("Bambuddy (unverified API shape)", () => {
|
||||
it("maps spools to colors: merged by name, enough grams is in stock, archived skipped", () => {
|
||||
describe("Bambuddy", () => {
|
||||
it("maps spools to colors: unnamed spools named by color, merged by name, grams left decide stock, archived skipped", () => {
|
||||
const spools = mapBambuddySpools(fixture("bambuddy-spools.sample.json"));
|
||||
expect(spools.map((s) => s.name)).toEqual(["Jade White", "Jade White", "Bambu Green", "Translucent Orange", "Charcoal"]);
|
||||
expect(spools.map((s) => s.name)).toEqual(["Black", "Jade White", "Jade White", "Bambu Green", "Translucent Orange"]);
|
||||
expect(spools[0]).toEqual({ name: "Black", hex: "#000000", material: "PLA", remaining: 1000 - 6.07 });
|
||||
expect(filamentOptions(spools)).toEqual([
|
||||
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
{ name: "Black", hex: "#000000", inStock: true },
|
||||
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
{ name: "Translucent Orange", hex: "#ff6a13", inStock: true },
|
||||
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
|
||||
]);
|
||||
expect(filamentOptions(spools, { materials: ["PETG"] }).map((o) => o.name)).toEqual(["Translucent Orange"]);
|
||||
expect(mapBambuddySpools([{ name: "Plain list" }])).toEqual([{ name: "Plain list" }]);
|
||||
expect(mapBambuddySpools({ items: [{ color_name: "Wrapped" }] })).toEqual([{ name: "Wrapped" }]);
|
||||
expect(basicColorName("#1e3a8a")).toBe("Blue");
|
||||
expect(() => mapBambuddySpools({ nothing: true })).toThrow(/no list/);
|
||||
});
|
||||
|
||||
@@ -316,10 +318,10 @@ describe("Bambuddy (unverified API shape)", () => {
|
||||
const cards = r.put(line("Cards", "Finish"));
|
||||
r.service.scan();
|
||||
const f = fakeFetch(() => ok(fixture("bambuddy-spools.sample.json")));
|
||||
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", path: "/api/v1/spools", apiKey: "k" }), f.fn).runNow("bambuddy");
|
||||
const report = await scheduler(bambuddyJob({ url: "http://bambuddy:8000/", apiKey: "k" }), f.fn).runNow("bambuddy");
|
||||
expect(report).toMatchObject({ ok: true, counts: { colors: 4, inStock: 3, lines: 1 } });
|
||||
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/spools");
|
||||
expect(headerOf(f.calls[0]!, "authorization")).toBe("Bearer k");
|
||||
expect(f.calls[0]!.url).toBe("http://bambuddy:8000/api/v1/inventory/spools");
|
||||
expect(headerOf(f.calls[0]!, "x-api-key")).toBe("k");
|
||||
expect(r.store.syncCache.forPath(printed).filamentStock).toEqual([{ group: "Filament", options: expect.arrayContaining([{ name: "Jade White", hex: "#f5f5dc", inStock: true }]) }]);
|
||||
expect(r.store.syncCache.forPath(cards)).toEqual({});
|
||||
// Notes are never written.
|
||||
|
||||
Reference in New Issue
Block a user