From 449ffcd62dd868141f3529145b01ceea9fa9d764 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:47:48 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx --- docs/content-service.md | 24 +++--- services/content/.env.example | 5 +- services/content/src/sync/jobs/bambuddy.ts | 83 +++++++++++-------- services/content/test/sync/fixtures/README.md | 2 +- .../sync/fixtures/bambuddy-spools.sample.json | 19 ++--- services/content/test/sync/homelab.test.ts | 22 ++--- 6 files changed, 84 insertions(+), 71 deletions(-) diff --git a/docs/content-service.md b/docs/content-service.md index acceec6..c504b28 100644 --- a/docs/content-service.md +++ b/docs/content-service.md @@ -148,7 +148,7 @@ being down never takes a page down. Code: [`services/content/src/sync/`](../serv | `covers` | 15 min | Library items with no `cover`: Open Library (books), TMDB (film, TV; needs `TMDB_API_KEY`), AniList (anime, manga, manhwa, light novels, webnovels; by `anilistId` when set) | `cover` | | `suwayomi` | hour | PersonalReader's manga and manhwa: Suwayomi's GraphQL library (category, furthest chapter read and when, AniList tracker id), matched to Library items by `anilistId`; AniList for covers | `chapterProgress` (`title`, `status`, `chapter`, `readAt`), `cover` (AniList); changes `state` (below) | | `kavita` | day | PersonalReader's light novels and books: Kavita's API, matched by `kavitaSeriesId`, or by `anilistId` against an anilist.co link in the series' metadata | `bookProgress` (`title`, `percent`, `readAt`); changes `state` (below) | -| `bambuddy` | hour | Filament spools in stock (**unverified API**, below) | `filamentStock` on product lines with a Filament option | +| `bambuddy` | hour | Filament spools in stock | `filamentStock` on product lines with a Filament option | | `homebox` | hour | Homebox items with the labels or locations in `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` | `materialStock` on product lines with a Leather or Paper option | The covers job looks every 15 minutes, so a new Library item gets a cover @@ -227,15 +227,15 @@ stock when the source does not have it; in-stock colors the note does not list are added after the note's own. With nothing synced, options show exactly as the note says (a missing `inStock` counts as in stock). -- **Bambuddy: unverified.** Its API is not documented anywhere this project - has, and no request has been made to one. The endpoint is - `BAMBUDDY_SPOOLS_PATH` (default `/api/v1/inventory/spools`, a guess), and all - knowledge of the response is in `mapBambuddySpools` in - `sync/jobs/bambuddy.ts`: it accepts a list (or one under `items`, `spools`, - `data`, `results`) and reads color name, hex, material and grams left under - common spellings. Check it against Greg's Bambuddy and fix that one function - before relying on it. A color is in stock when some spool of it has at least - `BAMBUDDY_MIN_GRAMS` (default 50) left, or does not say. +- **Bambuddy** reads `GET /api/v1/inventory/spools` (`BAMBUDDY_SPOOLS_PATH`) + with an API key made in Bambuddy's Settings, sent in the `X-API-Key` header. + Field names were checked against Greg's Bambuddy (2026-10-12) and are all in + `mapBambuddySpools` in `sync/jobs/bambuddy.ts`. Grams left is `label_weight` + minus `weight_used`; archived spools are skipped. A spool with no + `color_name` (RFID spools) is named by its nearest plain color (Black, + Blue...); setting a color name in Bambuddy overrides that. A color is in + stock when some spool of it has at least `BAMBUDDY_MIN_GRAMS` (default 50) + left. - **Homebox** logs in at `POST /api/v1/users/login`, looks up the label and location ids, and lists `GET /api/v1/items?labels=` (or `locations=`). Each `HOMEBOX_LABELS` / `HOMEBOX_LOCATIONS` entry is @@ -250,8 +250,8 @@ exactly as the note says (a missing `inStock` counts as in stock). | `SUWAYOMI_AUTH` | `ui_login` (default with a user), `basic`, or `none`. | | `KAVITA_URL`, `KAVITA_API_KEY` | Enable `kavita`, like `http://kavita:5000`; the key of a Kavita user that can see the libraries. | | `BAMBUDDY_URL` | Enables `bambuddy`. | -| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`, unverified). | -| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | Sent as `Authorization: Bearer `, or as the raw key in the named header. | +| `BAMBUDDY_SPOOLS_PATH` | Endpoint path (default `/api/v1/inventory/spools`). | +| `BAMBUDDY_API_KEY`, `BAMBUDDY_AUTH_HEADER` | The API key from Bambuddy's Settings, sent in `X-API-Key` (or in the named header). | | `BAMBUDDY_GROUP`, `BAMBUDDY_MATERIALS`, `BAMBUDDY_MIN_GRAMS` | Option group (Filament), materials to include (like `PLA,PETG`; all when empty), grams that count as in stock (50). | | `HOMEBOX_URL`, `HOMEBOX_USER`, `HOMEBOX_PASS` | Enable `homebox`, with a read-only Homebox user. | | `HOMEBOX_LABELS`, `HOMEBOX_LOCATIONS` | Which items count, `name` or `name=Group`, comma separated. Labels default to `leather,paper`. | diff --git a/services/content/.env.example b/services/content/.env.example index 3e016ce..aa6ac4b 100644 --- a/services/content/.env.example +++ b/services/content/.env.example @@ -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 diff --git a/services/content/src/sync/jobs/bambuddy.ts b/services/content/src/sync/jobs/bambuddy.ts index 1c6332a..71723ee 100644 --- a/services/content/src/sync/jobs/bambuddy.ts +++ b/services/content/src/sync/jobs/bambuddy.ts @@ -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, 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 | unknown[]; - const list = Array.isArray(root) - ? root - : (["items", "spools", "data", "results"].map((k) => (root as Record)?.[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 | 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 === 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 = { 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 }]); diff --git a/services/content/test/sync/fixtures/README.md b/services/content/test/sync/fixtures/README.md index 0444a57..207df44 100644 --- a/services/content/test/sync/fixtures/README.md +++ b/services/content/test/sync/fixtures/README.md @@ -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 /api/Series/all-v2` (SeriesDto list) | | `kavita-metadata.sample.json` | `GET /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 /api/v1/labels` | | `homebox-items.sample.json` | `GET /api/v1/items?labels=…` | diff --git a/services/content/test/sync/fixtures/bambuddy-spools.sample.json b/services/content/test/sync/fixtures/bambuddy-spools.sample.json index 49b7ef2..2a47d1f 100644 --- a/services/content/test/sync/fixtures/bambuddy-spools.sample.json +++ b/services/content/test/sync/fixtures/bambuddy-spools.sample.json @@ -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" } +] diff --git a/services/content/test/sync/homelab.test.ts b/services/content/test/sync/homelab.test.ts index 4275ad6..82c09ef 100644 --- a/services/content/test/sync/homelab.test.ts +++ b/services/content/test/sync/homelab.test.ts @@ -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.