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
@@ -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" }
]
+12 -10
View File
@@ -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.