From 91d2adddcccc647c90881be409e34d77682acf88 Mon Sep 17 00:00:00 2001 From: bubulux Date: Fri, 23 Oct 2026 16:11:42 +0200 Subject: [PATCH 1/2] Lights: fixture kinds, fixtures and mesh networks on the Worker (#208) Shared fixture kinds and capabilities in packages/vocab/src/lighting.ts. Migration 0027 adds fixtures and mesh_networks (no key material); /api/fixtures and /api/mesh-networks follow the rigs' upsert and workspace rules, with strict kind, address and mesh checks. Both are in the tenancy map and the test world; fixtures.test.ts covers the rules. Co-Authored-By: Claude Opus 5.5 --- apps/worker/migrations/0027_fixtures.sql | 35 +++++++ apps/worker/src/fixtures.ts | 113 +++++++++++++++++++++++ apps/worker/src/routes.ts | 2 + apps/worker/src/tenancy.ts | 2 + apps/worker/test/routes/fixtures.test.ts | 75 +++++++++++++++ apps/worker/test/routes/helpers.ts | 7 +- apps/worker/test/routes/tenancy.test.ts | 16 +++- docs/api.md | 2 + docs/data-model.md | 5 + packages/vocab/src/index.ts | 1 + packages/vocab/src/lighting.test.ts | 27 ++++++ packages/vocab/src/lighting.ts | 77 +++++++++++++++ 12 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 apps/worker/migrations/0027_fixtures.sql create mode 100644 apps/worker/src/fixtures.ts create mode 100644 apps/worker/test/routes/fixtures.test.ts create mode 100644 packages/vocab/src/lighting.test.ts create mode 100644 packages/vocab/src/lighting.ts diff --git a/apps/worker/migrations/0027_fixtures.sql b/apps/worker/migrations/0027_fixtures.sql new file mode 100644 index 0000000..dc42b6f --- /dev/null +++ b/apps/worker/migrations/0027_fixtures.sql @@ -0,0 +1,35 @@ +-- Lights (epic #206, issue #208): the fixtures a workspace controls from the phone, and the +-- Bluetooth Mesh networks the phone provisions for mesh fixtures (Aputure). Additive. +-- +-- No key material here: a mesh network's NetKey, AppKey and device keys stay in the phone's secure +-- store. A network row only carries what another phone or the Studio may know about it. + +CREATE TABLE mesh_networks ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, + iv_index INTEGER NOT NULL DEFAULT 0, + -- The next free unicast address the provisioner hands out. + next_unicast INTEGER NOT NULL DEFAULT 2, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT +); +CREATE INDEX mesh_networks_workspace_idx ON mesh_networks (workspace_id); + +CREATE TABLE fixtures ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + label TEXT NOT NULL, + -- FIXTURE_KINDS in packages/vocab/src/lighting.ts. + kind_id TEXT NOT NULL, + -- The Bluetooth address as Android reports it ("AA:BB:CC:DD:EE:FF"). + address TEXT NOT NULL, + -- Mesh fixtures once provisioned; NULL for GATT fixtures and unprovisioned mesh ones. + mesh_network_id TEXT REFERENCES mesh_networks(id) ON DELETE SET NULL, + mesh_unicast INTEGER, + mesh_elements INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT +); +CREATE INDEX fixtures_workspace_idx ON fixtures (workspace_id); +CREATE UNIQUE INDEX fixtures_unicast_idx ON fixtures (mesh_network_id, mesh_unicast) WHERE mesh_network_id IS NOT NULL; diff --git a/apps/worker/src/fixtures.ts b/apps/worker/src/fixtures.ts new file mode 100644 index 0000000..f88cb7f --- /dev/null +++ b/apps/worker/src/fixtures.ts @@ -0,0 +1,113 @@ +import { fixtureKind, FIXTURE_LABEL_MAX, isBleAddress, MESH_UNICAST_MAX } from "@openclapper/vocab"; +import { assertNumber, assertString, assertUuid, HttpError, json, readJson, type Router } from "@openclapper/service-kit"; +import type { Ctx } from "./index.ts"; +import { accessIfExists, assertAccess, assertRank, defaultWorkspace, inWorkspaces, workspaceOf } from "./tenancy.ts"; + +export interface MeshNetworkRow { + id: string; workspace_id: string; name: string; iv_index: number; next_unicast: number; created_at: string; updated_at: string | null; +} +export interface FixtureRow { + id: string; workspace_id: string; label: string; kind_id: string; address: string; + mesh_network_id: string | null; mesh_unicast: number | null; mesh_elements: number | null; created_at: string; updated_at: string | null; +} + +const assertInt = (v: unknown, field: string, min: number, max: number): number => { + const n = assertNumber(v, field, { min, max }); + if (!Number.isInteger(n)) throw new HttpError(400, `${field} must be a whole number`); + return n; +}; + +/** A new row goes to `workspace_id` (default: the caller's own workspace); an existing one keeps its workspace. */ +const targetWorkspace = async (ctx: Ctx, kind: "fixture" | "mesh_network", id: string, b: Record) => + (await accessIfExists(ctx, kind, id, "editor")) + ?? (b.workspace_id == null ? await defaultWorkspace(ctx) : assertRank(ctx, assertUuid(b.workspace_id, "workspace_id"), "editor", "workspace not found")); + +/** + * Lights (epic #206): fixtures and the Bluetooth Mesh networks the phone provisions for them. Both + * belong to a workspace and are shared by its projects, like rigs. Client-owned ids, upsert. Mesh + * keys never reach the server; the phone keeps them. + */ +export function registerFixtureRoutes(r: Router) { + r.on("GET", "/api/mesh-networks", async ({ env, tenant }) => { + const { results } = await env.DB.prepare(`SELECT * FROM mesh_networks WHERE ${inWorkspaces("workspace_id", 1)} ORDER BY name COLLATE NOCASE`) + .bind(tenant.wsList).all(); + return json({ mesh_networks: results }); + }); + + /** JSON { name, iv_index?, next_unicast?, workspace_id? }. The phone is the provisioner, so its counters win. */ + r.on("PUT", "/api/mesh-networks/:id", async (ctx, { id }) => { + const { env, request } = ctx; + const nid = assertUuid(id, "id"); + const b = await readJson>(request); + const ws = await targetWorkspace(ctx, "mesh_network", nid, b); + const name = assertString(b.name, "name", FIXTURE_LABEL_MAX); + const iv = assertInt(b.iv_index ?? 0, "iv_index", 0, 0xffffffff); + const next = assertInt(b.next_unicast ?? 2, "next_unicast", 1, MESH_UNICAST_MAX + 1); + await env.DB.prepare( + `INSERT INTO mesh_networks (id, workspace_id, name, iv_index, next_unicast, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET name = ?3, iv_index = ?4, next_unicast = ?5, updated_at = ?6`, + ).bind(nid, ws, name, iv, next, new Date().toISOString()).run(); + return json({ mesh_network: await env.DB.prepare("SELECT * FROM mesh_networks WHERE id = ?1").bind(nid).first() }); + }); + + /** Its fixtures stay, unprovisioned: the lights still hold the old keys until they are reset. */ + r.on("DELETE", "/api/mesh-networks/:id", async (ctx, { id }) => { + const { env } = ctx; + const nid = assertUuid(id, "id"); + await assertAccess(ctx, "mesh_network", nid, "editor"); + await env.DB.batch([ + env.DB.prepare("UPDATE fixtures SET mesh_network_id = NULL, mesh_unicast = NULL, mesh_elements = NULL, updated_at = ?2 WHERE mesh_network_id = ?1").bind(nid, new Date().toISOString()), + env.DB.prepare("DELETE FROM mesh_networks WHERE id = ?1").bind(nid), + ]); + return json({ deleted: nid }); + }); + + r.on("GET", "/api/fixtures", async ({ env, tenant }) => { + const { results } = await env.DB.prepare(`SELECT * FROM fixtures WHERE ${inWorkspaces("workspace_id", 1)} ORDER BY label COLLATE NOCASE`) + .bind(tenant.wsList).all(); + return json({ fixtures: results }); + }); + + /** + * JSON { label, kind_id, address, mesh_network_id?, mesh_unicast?, mesh_elements?, workspace_id? }. + * Strict (a pairing is an interactive edit): the kind must be known, mesh fields only on a mesh + * kind and only with a network of the fixture's own workspace, one unicast address per network. + */ + r.on("PUT", "/api/fixtures/:id", async (ctx, { id }) => { + const { env, request } = ctx; + const fid = assertUuid(id, "id"); + const b = await readJson>(request); + const ws = await targetWorkspace(ctx, "fixture", fid, b); + const label = assertString(b.label, "label", FIXTURE_LABEL_MAX); + const kind = typeof b.kind_id === "string" ? fixtureKind(b.kind_id) : undefined; + if (!kind) throw new HttpError(400, "kind_id is not a known fixture kind"); + if (!isBleAddress(b.address)) throw new HttpError(400, "address must be a Bluetooth address like AA:BB:CC:DD:EE:FF"); + + let network: string | null = null, unicast: number | null = null, elements: number | null = null; + if (b.mesh_network_id != null) { + if (kind.transport !== "mesh") throw new HttpError(400, `${kind.id} is not a mesh fixture`); + network = assertUuid(b.mesh_network_id, "mesh_network_id"); + // Another workspace's network answers like a missing one (tenancy.ts assertRef). + if ((await workspaceOf(env, "mesh_network", network)) !== ws) throw new HttpError(400, "mesh_network_id does not exist"); + unicast = assertInt(b.mesh_unicast, "mesh_unicast", 1, MESH_UNICAST_MAX); + elements = assertInt(b.mesh_elements ?? 1, "mesh_elements", 1, 255); + const taken = await env.DB.prepare("SELECT id FROM fixtures WHERE mesh_network_id = ?1 AND mesh_unicast = ?2 AND id != ?3").bind(network, unicast, fid).first(); + if (taken) throw new HttpError(409, "another fixture has that unicast address in this network"); + } + + await env.DB.prepare( + `INSERT INTO fixtures (id, workspace_id, label, kind_id, address, mesh_network_id, mesh_unicast, mesh_elements, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO UPDATE SET label = ?3, kind_id = ?4, address = ?5, mesh_network_id = ?6, mesh_unicast = ?7, mesh_elements = ?8, updated_at = ?9`, + ).bind(fid, ws, label, kind.id, b.address, network, unicast, elements, new Date().toISOString()).run(); + return json({ fixture: await env.DB.prepare("SELECT * FROM fixtures WHERE id = ?1").bind(fid).first() }); + }); + + r.on("DELETE", "/api/fixtures/:id", async (ctx, { id }) => { + const { env } = ctx; + const fid = assertUuid(id, "id"); + await assertAccess(ctx, "fixture", fid, "editor"); + await env.DB.prepare("DELETE FROM fixtures WHERE id = ?1").bind(fid).run(); + return json({ deleted: fid }); + }); +} diff --git a/apps/worker/src/routes.ts b/apps/worker/src/routes.ts index 4d29260..e6c241b 100644 --- a/apps/worker/src/routes.ts +++ b/apps/worker/src/routes.ts @@ -10,6 +10,7 @@ import { registerDayRoutes } from "./days.ts"; import { registerDeviceRoutes } from "./devices.ts"; import { registerFieldRoutes } from "./fields.ts"; import { registerFileRoutes } from "./files.ts"; +import { registerFixtureRoutes } from "./fixtures.ts"; import { registerFramingRoutes } from "./framings.ts"; import type { Ctx } from "./index.ts"; import { registerLayoutRoutes } from "./layouts.ts"; @@ -59,6 +60,7 @@ router.on("GET", "/api/me", async ({ env, identity }) => { return json({ user, workspaces: results.map((w) => ({ ...w, personal: w.personal === 1 })) }); }); registerPresetRoutes(router); +registerFixtureRoutes(router); registerProjectRoutes(router); registerFieldRoutes(router); registerDayRoutes(router); diff --git a/apps/worker/src/tenancy.ts b/apps/worker/src/tenancy.ts index 9412091..367e04d 100644 --- a/apps/worker/src/tenancy.ts +++ b/apps/worker/src/tenancy.ts @@ -61,6 +61,8 @@ const WORKSPACE_OF = { framing: `SELECT p.workspace_id AS ws FROM framings f JOIN photos ph ON ph.id = f.photo_id JOIN shots s ON s.id = ph.shot_id JOIN projects p ON p.id = s.project_id WHERE f.id = ?1`, preset: "SELECT workspace_id AS ws FROM presets WHERE id = ?1", + fixture: "SELECT workspace_id AS ws FROM fixtures WHERE id = ?1", + mesh_network: "SELECT workspace_id AS ws FROM mesh_networks WHERE id = ?1", } as const; export type Owned = keyof typeof WORKSPACE_OF; diff --git a/apps/worker/test/routes/fixtures.test.ts b/apps/worker/test/routes/fixtures.test.ts new file mode 100644 index 0000000..24dd13d --- /dev/null +++ b/apps/worker/test/routes/fixtures.test.ts @@ -0,0 +1,75 @@ +/** + * Lights (#208): fixtures and mesh networks. Workspace isolation is in tenancy.test.ts; this file + * covers the rules of the routes themselves. + */ +import { beforeAll, describe, expect, test } from "vitest"; +import { api, fixtureBody, newUser, ok, uuid, type User } from "./helpers.ts"; + +type Fixture = { id: string; workspace_id: string; kind_id: string; mesh_network_id: string | null; mesh_unicast: number | null; mesh_elements: number | null }; + +describe("fixtures", () => { + let u: User; + beforeAll(async () => { u = await newUser(); }); + + const net = async () => { + const id = uuid(); + await ok(api(u.token, "PUT", `/api/mesh-networks/${id}`, { name: "Set A" })); + return id; + }; + + test("a GATT light: created in the caller's own workspace, listed, kept there on update", async () => { + const id = uuid(); + const made = await ok<{ fixture: Fixture }>(api(u.token, "PUT", `/api/fixtures/${id}`, { label: "Key", kind_id: "neewer-fs230b", address: "C8:47:8C:00:11:22" })); + expect(made.fixture).toMatchObject({ id, kind_id: "neewer-fs230b", mesh_network_id: null, mesh_unicast: null }); + await ok(api(u.token, "PUT", `/api/fixtures/${id}`, { label: "Key left", kind_id: "neewer-fs230b", address: "C8:47:8C:00:11:22" })); + const list = await ok<{ fixtures: (Fixture & { label: string })[] }>(api(u.token, "GET", "/api/fixtures")); + expect(list.fixtures.find((f) => f.id === id)).toMatchObject({ label: "Key left", workspace_id: made.fixture.workspace_id }); + }); + + test.each([ + ["an unknown kind", { kind_id: "neewer-sl90" }], + ["a lower-case address", { address: "c8:47:8c:00:11:22" }], + ["no label", { label: " " }], + ])("refuses %s", async (_name, patch) => { + await ok(api(u.token, "PUT", `/api/fixtures/${uuid()}`, { ...fixtureBody("x"), ...patch }), 400); + }); + + test("mesh fields: only on a mesh kind, only with a known network, one unicast address each", async () => { + const n = await net(); + await ok(api(u.token, "PUT", `/api/fixtures/${uuid()}`, { label: "x", kind_id: "neewer-fs300b", address: "C8:47:8C:00:11:23", mesh_network_id: n, mesh_unicast: 2 }), 400); + await ok(api(u.token, "PUT", `/api/fixtures/${uuid()}`, fixtureBody("x", { mesh_network_id: uuid(), mesh_unicast: 2 })), 400); + await ok(api(u.token, "PUT", `/api/fixtures/${uuid()}`, fixtureBody("x", { mesh_network_id: n })), 400); + const a = uuid(); + const first = await ok<{ fixture: Fixture }>(api(u.token, "PUT", `/api/fixtures/${a}`, fixtureBody("MC 1", { mesh_network_id: n, mesh_unicast: 2 }))); + expect(first.fixture).toMatchObject({ mesh_network_id: n, mesh_unicast: 2, mesh_elements: 1 }); + await ok(api(u.token, "PUT", `/api/fixtures/${uuid()}`, fixtureBody("MC 2", { mesh_network_id: n, mesh_unicast: 2 })), 409); + // The same fixture saved again keeps its own address. + await ok(api(u.token, "PUT", `/api/fixtures/${a}`, fixtureBody("MC 1 renamed", { mesh_network_id: n, mesh_unicast: 2 }))); + }); + + test("deleting a network leaves its fixtures, unprovisioned", async () => { + const n = await net(); + const a = uuid(); + await ok(api(u.token, "PUT", `/api/fixtures/${a}`, fixtureBody("MC", { mesh_network_id: n, mesh_unicast: 5, mesh_elements: 2 }))); + await ok(api(u.token, "DELETE", `/api/mesh-networks/${n}`)); + const list = await ok<{ fixtures: Fixture[] }>(api(u.token, "GET", "/api/fixtures")); + expect(list.fixtures.find((f) => f.id === a)).toMatchObject({ mesh_network_id: null, mesh_unicast: null, mesh_elements: null }); + const nets = await ok<{ mesh_networks: { id: string }[] }>(api(u.token, "GET", "/api/mesh-networks")); + expect(nets.mesh_networks.some((x) => x.id === n)).toBe(false); + }); + + test("network counters are the phone's and are validated", async () => { + const n = uuid(); + const made = await ok<{ mesh_network: { iv_index: number; next_unicast: number } }>(api(u.token, "PUT", `/api/mesh-networks/${n}`, { name: "Set B", iv_index: 3, next_unicast: 12 })); + expect(made.mesh_network).toMatchObject({ iv_index: 3, next_unicast: 12 }); + await ok(api(u.token, "PUT", `/api/mesh-networks/${n}`, { name: "Set B", next_unicast: 1.5 }), 400); + await ok(api(u.token, "PUT", `/api/mesh-networks/${n}`, { name: "Set B", iv_index: -1 }), 400); + }); + + test("deleting a fixture", async () => { + const a = uuid(); + await ok(api(u.token, "PUT", `/api/fixtures/${a}`, fixtureBody("gone"))); + await ok(api(u.token, "DELETE", `/api/fixtures/${a}`)); + await ok(api(u.token, "DELETE", `/api/fixtures/${a}`), 404); + }); +}); diff --git a/apps/worker/test/routes/helpers.ts b/apps/worker/test/routes/helpers.ts index e671265..03d59fc 100644 --- a/apps/worker/test/routes/helpers.ts +++ b/apps/worker/test/routes/helpers.ts @@ -85,6 +85,7 @@ export interface World { project: string; preset: string; location: string; spot: string; field: string; view: string; binnedView: string; shot: string; photo: string; day: string; framing: string; timeline: string; clip: string; overlay: string; sketch: string; folder: string; desktop: string; device: string; layout: string; + meshNetwork: string; fixture: string; } const DEVICE_SPECS = { v: 1, model: "Test phone", viewport: { portrait: { width: 400, height: 800 }, landscape: null }, zoom: null, stops: [1], mainEquivalentMm: 24, calibration: "auto" }; @@ -93,6 +94,8 @@ export const deviceBody = (name: string) => ({ name, specs: DEVICE_SPECS }); export const layoutBody = (name: string) => ({ name, layout: DEFAULT_VIEWFINDER_LAYOUT }); export const overlayBody = (photo: string, name: string) => ({ photo_id: photo, name, drawing: emptyDrawing(true), presentation: { mode: "fit" } }); export const sketchBody = (shot: string, name: string) => ({ shot_id: shot, name, drawing: emptyDrawing(false), aspect: 1.5 }); +/** An Aputure MC (a mesh kind); `extra` adds its network, unicast, workspace. */ +export const fixtureBody = (label: string, extra: Record = {}) => ({ label, kind_id: "aputure-mc", address: "A4:C1:38:0B:2F:9E", ...extra }); export const timelineBody = (project: string, name: string, clip: string, photo: string) => ({ project_id: project, name, clips: [{ id: clip, photo_id: photo, presentation: { mode: "fit", frame: FRAME }, duration_ms: 2000 }] }); @@ -113,7 +116,7 @@ export async function buildWorld(user: User): Promise { const w = { user, mark, workspace: id(), project: id(), preset: id(), location: id(), spot: id(), field: id(), view: id(), binnedView: id(), shot: id(), photo: id(), day: id(), framing: id(), timeline: id(), clip: id(), overlay: id(), sketch: id(), folder: id(), - desktop: id(), device: id(), layout: id(), + desktop: id(), device: id(), layout: id(), meshNetwork: id(), fixture: id(), } as World; memberUser ??= await newUser({ signIn: false }); w.member = memberUser.id; @@ -139,6 +142,8 @@ export async function buildWorld(user: User): Promise { await ok(api(t, "PUT", `/api/desktop/${w.desktop}`, { project_id: w.project, kind: "app", app_id: "gallery", label: `${mark} app`, x: 10, y: 10 })); await ok(api(t, "PUT", `/api/devices/${w.device}`, deviceBody(`${mark} device`))); await ok(api(t, "PUT", `/api/viewfinder-layouts/${w.layout}`, { ...layoutBody(`${mark} layout`), device_id: w.device })); + await ok(api(t, "PUT", `/api/mesh-networks/${w.meshNetwork}`, { name: `${mark} mesh`, workspace_id: w.workspace })); + await ok(api(t, "PUT", `/api/fixtures/${w.fixture}`, fixtureBody(`${mark} light`, { mesh_network_id: w.meshNetwork, mesh_unicast: 2, workspace_id: w.workspace }))); return w; } diff --git a/apps/worker/test/routes/tenancy.test.ts b/apps/worker/test/routes/tenancy.test.ts index e1788b4..d9e6913 100644 --- a/apps/worker/test/routes/tenancy.test.ts +++ b/apps/worker/test/routes/tenancy.test.ts @@ -13,7 +13,7 @@ import { env } from "cloudflare:workers"; import { beforeAll, describe, expect, test } from "vitest"; import { publicRouter, router } from "../../src/routes.ts"; -import { api, buildWorld, deviceBody, FRAME, idsOf, layoutBody, newUser, ok, overlayBody, photoMeta, sketchBody, snapshot, uploadForm, uuid, type User, type World } from "./helpers.ts"; +import { api, buildWorld, deviceBody, fixtureBody, FRAME, idsOf, layoutBody, newUser, ok, overlayBody, photoMeta, sketchBody, snapshot, uploadForm, uuid, type User, type World } from "./helpers.ts"; interface IdCase { kind: "id"; @@ -46,6 +46,14 @@ const ROUTES: Record = { "PUT /api/presets/:id": { kind: "id", path: (a) => `/api/presets/${a.preset}`, body: (a) => ({ name: named(a, "rig"), sensor_width_mm: 30, sensor_height_mm: 20 }) }, "DELETE /api/presets/:id": { kind: "id", path: (a) => `/api/presets/${a.preset}` }, + "GET /api/mesh-networks": { kind: "list", paths: () => ["/api/mesh-networks"] }, + "PUT /api/mesh-networks/:id": { kind: "id", path: (a) => `/api/mesh-networks/${a.meshNetwork}`, body: (a) => ({ name: named(a, "mesh"), next_unicast: 3 }) }, + "DELETE /api/mesh-networks/:id": { kind: "id", path: (a) => `/api/mesh-networks/${a.meshNetwork}` }, + "GET /api/fixtures": { kind: "list", paths: () => ["/api/fixtures"] }, + // B names A's network as well: the realistic attack on the upsert. + "PUT /api/fixtures/:id": { kind: "id", path: (a) => `/api/fixtures/${a.fixture}`, body: (a) => fixtureBody(named(a, "light"), { mesh_network_id: a.meshNetwork, mesh_unicast: 2 }) }, + "DELETE /api/fixtures/:id": { kind: "id", path: (a) => `/api/fixtures/${a.fixture}` }, + "GET /api/projects": { kind: "list", paths: () => ["/api/projects"] }, "PUT /api/projects/:id": { kind: "id", path: (a) => `/api/projects/${a.project}`, body: (a) => ({ name: named(a, "project") }) }, // A's project still has its shot: the delete answers 409 for A, past the workspace check. @@ -206,7 +214,7 @@ describe("another workspace's resources", () => { }); test("lists do show the owner's own resources (the leak check can fail)", async () => { - for (const path of ["/api/projects", "/api/presets", "/api/locations", "/api/spots", "/api/fields", "/api/views", "/api/days", "/api/timelines", "/api/shots", "/api/devices", "/api/viewfinder-layouts", "/api/workspaces"]) { + for (const path of ["/api/projects", "/api/presets", "/api/fixtures", "/api/mesh-networks", "/api/locations", "/api/spots", "/api/fields", "/api/views", "/api/days", "/api/timelines", "/api/shots", "/api/devices", "/api/viewfinder-layouts", "/api/workspaces"]) { const text = await (await api(a.token, "GET", path)).text(); expect(text, path).toContain(listWorld.mark); } @@ -227,6 +235,10 @@ describe("another workspace's resources", () => { ["a new shortcut in A's project", "PUT", "/api/desktop/{new}", (w) => ({ project_id: w.project, kind: "app", app_id: "gallery", x: 0, y: 0 })], ["a new project in A's workspace", "PUT", "/api/projects/{new}", (w) => ({ name: "x", workspace_id: w.workspace })], ["a new rig in A's workspace", "PUT", "/api/presets/{new}", (w) => ({ name: "x", sensor_width_mm: 36, sensor_height_mm: 24, workspace_id: w.workspace })], + ["a new mesh network in A's workspace", "PUT", "/api/mesh-networks/{new}", (w) => ({ name: "x", workspace_id: w.workspace })], + ["a new fixture in A's workspace", "PUT", "/api/fixtures/{new}", (w) => fixtureBody("x", { workspace_id: w.workspace })], + ["B's new fixture in A's mesh network", "PUT", "/api/fixtures/{new}", (w) => fixtureBody("x", { mesh_network_id: w.meshNetwork, mesh_unicast: 9 })], + ["B's fixture moved into A's mesh network", "PUT", "/api/fixtures/{b.fixture}", (w) => fixtureBody("x", { mesh_network_id: w.meshNetwork, mesh_unicast: 9 })], ["a new framing on A's photo", "PUT", "/api/framings/{new}", (w) => ({ photo_id: w.photo, name: "x", lens_mm: 35, frame: FRAME })], ["a framing on B's photo with A's rig", "PUT", "/api/framings/{new}", (w) => ({ photo_id: bWorld.photo, name: "x", rig_id: w.preset, lens_mm: 35, frame: FRAME })], ["a new overlay on A's photo", "PUT", "/api/overlays/{new}", (w) => overlayBody(w.photo, "x")], diff --git a/docs/api.md b/docs/api.md index b95c5b6..ec3b431 100644 --- a/docs/api.md +++ b/docs/api.md @@ -108,6 +108,8 @@ Shots embed both lists without the drawing; see [compose](features/compose.md) f | GET / PUT / DELETE | `/api/viewfinder-layouts[/:id]` | `{ name, layout, device_id? }`, layout checked by `validateViewfinderLayout` ([viewfinder layouts](features/viewfinder-layouts.md)); `device_id` absent keeps it, null clears it, an unknown one or another user's device is 400; a user's own (`user_id`): lists, PUT and DELETE see only yours | | GET / PUT / DELETE | `/api/devices[/:id]` | Device specs ([viewfinder layouts](features/viewfinder-layouts.md#device-specs-68)): PUT `{ name?, specs }`, specs checked by `validateDeviceSpecs`; `name` required to create, absent keeps the stored one (the phone's updates); a user's own, keyed `(user_id, id)`: PUT upserts the caller's own row (`201` new, `200` updated); the same phone id under another account is that account's row and is never touched (#224). DELETE clears the caller's layouts' `device_id` for it; `404` when the caller has no such device | | GET / PUT / DELETE | `/api/spots[/:id]` | `{ location_id, name, lat?, lon? }`: a place within a location ([projects](features/projects.md)); `location_id` required to create, never changes (GET takes `?project_id=`); names unique per location (409 + `existing_id`); `shot_count`; delete nulls `shots.spot_id` | +| GET / PUT / DELETE | `/api/fixtures[/:id]` | Lights ([lights](features/lights.md)): PUT `{ label, kind_id, address, mesh_network_id?, mesh_unicast?, mesh_elements?, workspace_id? }`, strict: `kind_id` from `FIXTURE_KINDS`, `address` like `AA:BB:CC:DD:EE:FF`, mesh fields only on a mesh kind and only with a network of the fixture's own workspace (another workspace's network answers `400` like a missing one), `409` when another fixture has that unicast address in the network. A new fixture goes to `workspace_id` (default the caller's own workspace); an existing one keeps its own. Lists hold the fixtures of all the caller's workspaces | +| GET / PUT / DELETE | `/api/mesh-networks[/:id]` | Bluetooth Mesh networks the phone provisions: PUT `{ name, iv_index?, next_unicast?, workspace_id? }` (the phone's counters, whole numbers). No keys. DELETE leaves its fixtures, with their mesh fields cleared | | GET / PUT / DELETE | `/api/presets[/:id]` | Rigs: `name, camera_id, format_id, sensor_width_mm, sensor_height_mm, speedbooster_factor, lens_min_mm, lens_max_mm`, plus `workspace_id` for a new rig (default the caller's own workspace; an existing rig keeps its own). Lists hold the rigs of all the caller's workspaces, each with its `workspace_id`. An upload naming a rig of another workspace stores the photo without it | | GET / PUT / DELETE | `/api/views[/:id]` | `{ project_id, name, filter }` (`project_id` required to create), filter validated by `validateFilter` | diff --git a/docs/data-model.md b/docs/data-model.md index 14f7612..961ec7b 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -6,6 +6,7 @@ D1 (SQLite) database `openclapper-db`, schema in `apps/worker/migrations/`. Imag | File | What | | --- | --- | +| `0027_fixtures.sql` | `mesh_networks` and `fixtures`: the lights a workspace controls from the phone and the Bluetooth Mesh networks the phone provisions. Additive (epic #206, issue #208; see [lights](features/lights.md)) | | `0026_devices_per_user.sql` | `devices` keyed `(user_id, id)` instead of `id`; `viewfinder_layouts` rebuilt with `device_id` as a composite reference to `devices(user_id, id)` (the layout owner's device) and an index on `(user_id, device_id)`. Both tables rebuilt, every row copied with its owner; a `device_id` that was not one of the layout owner's devices is cleared (#224) | | `0025_admin_audit.sql` | `admin_audit`: every change an operator makes in the admin plane, written in the same batch as the change. Additive (#224; see [admin](features/admin.md#audit-trail-224)) | | `0023_monitoring.sql` | `storage_daily` (a day's storage per workspace and the bucket total, written by the cron) and `admin_settings` (the admin plane's settings as JSON). Additive (epic #67, issue #214; see [monitoring](features/monitoring.md)) | @@ -68,6 +69,10 @@ Every project and rig belongs to a workspace, every device and viewfinder layout `viewfinder_layouts(id, user_id → users, name, layout JSON, device_id, created_at, updated_at)`, `(user_id, device_id) → devices(user_id, id)`: phone layouts made in the Studio; a user's own. The shipped default is in code. `device_id` is the device the layout is designed for, always one of the layout owner's own devices. The composite key has no ON DELETE action (SET NULL would clear `user_id` too): deleting a device clears `device_id` in the same batch (`devices.ts`). +`fixtures(id, workspace_id → workspaces, label, kind_id, address, mesh_network_id → mesh_networks SET NULL, mesh_unicast, mesh_elements, created_at, updated_at)`: lights (and the battery) paired on a phone ([lights](features/lights.md)). `kind_id` is one of `FIXTURE_KINDS` in `packages/vocab/src/lighting.ts`; `address` the Bluetooth address as Android reports it. The mesh columns are set only for a provisioned mesh fixture (Aputure), unique per network. + +`mesh_networks(id, workspace_id → workspaces, name, iv_index, next_unicast, created_at, updated_at)`: Bluetooth Mesh networks the phone created as provisioner. The keys (NetKey, AppKey, device keys) are never stored here; they stay in the phone's secure store. + `devices(user_id → users, id, name, specs JSON, created_at, updated_at)`, primary key `(user_id, id)`: phones read out in the Studio (issue #68), a user's own. The id is the phone's own (`device.v1` on the phone), so the phone keeps its user's row current; the same phone under another account is another row. `specs` (`DeviceSpecs` in `packages/vocab/src/devices.ts`): `{ v: 1, model, viewport: { portrait, landscape } (dp or null), zoom: { min, max } | null, stops: [0.5, 1, …], mainEquivalentMm, calibration: auto|manual }`. `views(id, project_id → projects CASCADE, name, filter JSON, …)`: a project's saved Studio filters (model in `packages/vocab/src/filter.ts`). diff --git a/packages/vocab/src/index.ts b/packages/vocab/src/index.ts index d734d25..dc88c25 100644 --- a/packages/vocab/src/index.ts +++ b/packages/vocab/src/index.ts @@ -9,3 +9,4 @@ export * from "./timeline.ts"; export * from "./viewfinder.ts"; export * from "./devices.ts"; export * from "./files.ts"; +export * from "./lighting.ts"; diff --git a/packages/vocab/src/lighting.test.ts b/packages/vocab/src/lighting.test.ts new file mode 100644 index 0000000..dc2b459 --- /dev/null +++ b/packages/vocab/src/lighting.test.ts @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { FIXTURE_KINDS, fixtureKind, isBleAddress } from "./lighting.ts"; + +test("kind ids are unique and stable", () => { + const ids = FIXTURE_KINDS.map((k) => k.id); + assert.equal(new Set(ids).size, ids.length); + assert.deepEqual(ids, ["neewer-fs230b", "neewer-fs300b", "neewer-ps099s", "aputure-mc"]); +}); + +test("capabilities are consistent", () => { + for (const k of FIXTURE_KINDS) { + if (k.caps.cct) assert.ok(k.caps.cct.min < k.caps.cct.max, k.id); + if (k.caps.cct?.gm) assert.ok(k.caps.cct.gm.min < 0 && k.caps.cct.gm.max > 0, k.id); + // A fixture with nothing to set reports something. + if (!k.caps.power && !k.caps.intensity) assert.ok(k.caps.telemetry.length > 0, k.id); + } + assert.equal(fixtureKind("aputure-mc")?.transport, "mesh"); + assert.equal(fixtureKind("nope"), undefined); +}); + +test("BLE addresses as Android reports them", () => { + assert.ok(isBleAddress("A4:C1:38:0B:2F:9E")); + assert.ok(!isBleAddress("a4:c1:38:0b:2f:9e")); + assert.ok(!isBleAddress("A4C1380B2F9E")); + assert.ok(!isBleAddress(null)); +}); diff --git a/packages/vocab/src/lighting.ts b/packages/vocab/src/lighting.ts new file mode 100644 index 0000000..7868a8f --- /dev/null +++ b/packages/vocab/src/lighting.ts @@ -0,0 +1,77 @@ +/** + * Lights (epic #206): the fixture kinds the phone can control over Bluetooth, and what each can do. + * Shared because the Worker validates a fixture's kind and the keyframe sequences (later) are + * planned against these capabilities in the Studio as well as on the phone. How a kind is reached + * (codecs, GATT services, mesh) lives with the phone's drivers (apps/mobile/src/fixtures). + */ + +/** The four dimming curves both makers' apps offer; also the easing between keyframes later. */ +export const CURVES = ["linear", "exp", "log", "s"] as const; +export type Curve = (typeof CURVES)[number]; + +/** Read-only values a fixture reports (the battery). */ +export const TELEMETRY_FIELDS = ["battery", "volts", "watts", "minutesLeft"] as const; +export type TelemetryField = (typeof TELEMETRY_FIELDS)[number]; + +export interface Capabilities { + power: boolean; + /** Intensity 0..1. */ + intensity: boolean; + /** White light in kelvin, with a green/magenta shift in the fixture's own steps (null = none). */ + cct: { min: number; max: number; gm: { min: number; max: number } | null } | null; + /** Hue 0..360, saturation and intensity 0..1. */ + hsi: boolean; + /** The fixture's own dimming-curve setting. */ + curves: boolean; + telemetry: readonly TelemetryField[]; +} + +/** "gatt": the phone talks to the fixture itself; "mesh": through a Bluetooth Mesh network the phone provisions. */ +export type FixtureTransport = "gatt" | "mesh"; + +export interface FixtureKind { + id: string; + brand: string; + model: string; + transport: FixtureTransport; + /** The phone's driver for it (apps/mobile/src/fixtures/registry.ts). */ + driver: string; + caps: Capabilities; +} + +const LIGHT = { power: true, intensity: true, hsi: false, curves: true, telemetry: [] } as const; + +/** + * Every supported fixture. Hardcoded on purpose: a new model is one entry here and one driver file + * on the phone. Ids are stored with each fixture, so they never change. + */ +export const FIXTURE_KINDS: readonly FixtureKind[] = [ + // Bi-colour COB lights of NEEWER's "Infinity" app; green/magenta unknown until the snoop log (#207). + { id: "neewer-fs230b", brand: "NEEWER", model: "FS230B", transport: "gatt", driver: "neewer-infinity", caps: { ...LIGHT, cct: { min: 2700, max: 6500, gm: null } } }, + { id: "neewer-fs300b", brand: "NEEWER", model: "FS300B", transport: "gatt", driver: "neewer-infinity", caps: { ...LIGHT, cct: { min: 2700, max: 6500, gm: null } } }, + // V-mount battery in the same app: telemetry only. + { + id: "neewer-ps099s", brand: "NEEWER", model: "PS099S", transport: "gatt", driver: "neewer-battery", + caps: { power: false, intensity: false, cct: null, hsi: false, curves: false, telemetry: TELEMETRY_FIELDS }, + }, + // Sidus Mesh RGBWW; green/magenta in the light's own 21 steps (−10..+10). + { id: "aputure-mc", brand: "Aputure", model: "MC", transport: "mesh", driver: "sidus-mesh", caps: { ...LIGHT, cct: { min: 3200, max: 6500, gm: { min: -10, max: 10 } }, hsi: true } }, +]; + +export const fixtureKind = (id: string): FixtureKind | undefined => FIXTURE_KINDS.find((k) => k.id === id); +export const fixtureKindName = (k: FixtureKind) => `${k.brand} ${k.model}`; + +/** What a light shows; every part optional (a command or a keyframe sets only some). */ +export interface LightState { + power?: boolean; + intensity?: number; + cct?: { kelvin: number; gm: number }; + hsi?: { h: number; s: number; i: number }; +} + +export const FIXTURE_LABEL_MAX = 60; +/** The highest unicast address of a Bluetooth Mesh network. */ +export const MESH_UNICAST_MAX = 0x7fff; + +/** A Bluetooth device address as Android reports it ("AA:BB:CC:DD:EE:FF"). */ +export const isBleAddress = (v: unknown): v is string => typeof v === "string" && /^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/.test(v); From e3db604f50e59e4b4d461ff00c5e58de9d48e9c2 Mon Sep 17 00:00:00 2001 From: bubulux Date: Fri, 23 Oct 2026 16:20:56 +0200 Subject: [PATCH 2/2] Lights: pairing, connection manager and synced fixture list on the phone (#208) Adds the Bluetooth library (@sfourdrinier/react-native-ble-plx 3.9.3, config plugin with scanning marked as never used for location) behind src/ble/link.ts. src/fixtures holds the driver seams (Link, Driver, Session), a per-link command queue with latest-wins coalescing, and a React-Native-free connection manager with leases, grace period, backoff and background suspend, all unit-tested with a fake link and fake timers. The fixture list syncs like the rigs and on every flush. Setup gains Lights: list, scan and pair, edit, and a light's screen showing its connection. No driver yet, so nothing can be controlled. Docs: features/lights.md, phone app, testing, known issues. The mobile test script now runs src/**/*.test.ts. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/App.tsx | 4 + apps/mobile/app.json | 10 +- apps/mobile/package.json | 3 +- apps/mobile/src/api.ts | 26 ++ apps/mobile/src/appState.tsx | 3 + apps/mobile/src/ble/link.ts | 65 +++++ apps/mobile/src/ble/permissions.ts | 14 ++ apps/mobile/src/fixtures/bytes.test.ts | 18 ++ apps/mobile/src/fixtures/bytes.ts | 34 +++ apps/mobile/src/fixtures/hub.ts | 61 +++++ apps/mobile/src/fixtures/hubCore.test.ts | 177 ++++++++++++++ apps/mobile/src/fixtures/hubCore.ts | 255 +++++++++++++++++++ apps/mobile/src/fixtures/library.ts | 67 +++++ apps/mobile/src/fixtures/queue.test.ts | 60 +++++ apps/mobile/src/fixtures/queue.ts | 73 ++++++ apps/mobile/src/fixtures/registry.test.ts | 16 ++ apps/mobile/src/fixtures/registry.ts | 42 ++++ apps/mobile/src/fixtures/types.ts | 70 ++++++ apps/mobile/src/screens/Lights.tsx | 209 ++++++++++++++++ apps/mobile/src/screens/Setup.tsx | 3 + apps/mobile/src/storage.ts | 6 + apps/mobile/src/uploads.ts | 2 + docs/README.md | 1 + docs/development.md | 2 +- docs/features/lights.md | 50 ++++ docs/features/phone-app.md | 5 +- docs/known-issues.md | 1 + docs/testing.md | 2 +- pnpm-lock.yaml | 285 ++-------------------- 29 files changed, 1289 insertions(+), 275 deletions(-) create mode 100644 apps/mobile/src/ble/link.ts create mode 100644 apps/mobile/src/ble/permissions.ts create mode 100644 apps/mobile/src/fixtures/bytes.test.ts create mode 100644 apps/mobile/src/fixtures/bytes.ts create mode 100644 apps/mobile/src/fixtures/hub.ts create mode 100644 apps/mobile/src/fixtures/hubCore.test.ts create mode 100644 apps/mobile/src/fixtures/hubCore.ts create mode 100644 apps/mobile/src/fixtures/library.ts create mode 100644 apps/mobile/src/fixtures/queue.test.ts create mode 100644 apps/mobile/src/fixtures/queue.ts create mode 100644 apps/mobile/src/fixtures/registry.test.ts create mode 100644 apps/mobile/src/fixtures/registry.ts create mode 100644 apps/mobile/src/fixtures/types.ts create mode 100644 apps/mobile/src/screens/Lights.tsx create mode 100644 docs/features/lights.md diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx index 04b3b44..cd72c7b 100644 --- a/apps/mobile/App.tsx +++ b/apps/mobile/App.tsx @@ -29,6 +29,7 @@ import { LoginForm, ProjectGate, SignInGate } from "./src/screens/Gates"; import { Offline } from "./src/screens/Offline"; import { History } from "./src/screens/History"; import { RigEditor, RigList } from "./src/screens/Rigs"; +import { FixtureControl, FixtureEditor, FixtureList } from "./src/screens/Lights"; import { Setup } from "./src/screens/Setup"; import { Account, Calibration, CaptureSettings, ChangePassword, DebugLog, ViewfinderSettings } from "./src/screens/SetupScreens"; import { CorrectPosition, MapFocus, ShotDetails } from "./src/screens/ShotDetails"; @@ -258,6 +259,9 @@ function Screen({ route }: { route: Route }) { case "step": return ; case "rigs": return ; case "rigEditor": return ; + case "fixtures": return ; + case "fixtureEditor": return ; + case "fixture": return ; case "viewfinderSettings": return ; case "captureSettings": return ; case "calibration": return ; diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 22635c4..960a946 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -31,7 +31,15 @@ "locationWhenInUsePermission": "OpenClapper tags each captured framing with its GPS position." } ], - "expo-font" + "expo-font", + [ + "@sfourdrinier/react-native-ble-plx", + { + "isBackgroundEnabled": false, + "neverForLocation": true, + "modes": [] + } + ] ] } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 944e6ee..83e6ff2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -8,6 +8,7 @@ "@expo/vector-icons": "^15.1.1", "@openclapper/fov-math": "workspace:*", "@openclapper/vocab": "workspace:*", + "@sfourdrinier/react-native-ble-plx": "3.9.3", "expo": "~57.0.28", "expo-constants": "~57.0.21", "expo-crypto": "~57.0.4", @@ -42,7 +43,7 @@ "typecheck": "tsc -p .", "start:lan": "expo start --go", "prebuild": "expo prebuild --platform android --no-install", - "test": "node --test src/*.test.ts" + "test": "node --test \"src/**/*.test.ts\"" }, "private": true } diff --git a/apps/mobile/src/api.ts b/apps/mobile/src/api.ts index cdb5d14..3ae502a 100644 --- a/apps/mobile/src/api.ts +++ b/apps/mobile/src/api.ts @@ -4,6 +4,7 @@ import { API_URL, isConfigured } from "./config"; import { log } from "./log"; import { isOfflineMode, markOnline } from "./net"; import type { DeviceSpecs, Extra, FieldDefinition, Framing, Presentation } from "@openclapper/vocab"; +import type { Fixture } from "./fixtures/types"; import type { LocationEntry, Preset, ProjectEntry, ShotMetadata, ShotTags, SpotEntry, LayoutEntry } from "./types"; export class ApiError extends Error { @@ -112,6 +113,18 @@ function fromServer(s: ServerPreset): Preset { }; } +interface ServerFixture { + id: string; label: string; kind_id: string; address: string; + mesh_network_id: string | null; mesh_unicast: number | null; mesh_elements: number | null; created_at: string; +} + +function fixtureFromServer(s: ServerFixture): Fixture { + return { + id: s.id, label: s.label, kindId: s.kind_id, address: s.address, createdAt: s.created_at, synced: true, + mesh: s.mesh_network_id && s.mesh_unicast != null ? { networkId: s.mesh_network_id, unicast: s.mesh_unicast, elements: s.mesh_elements ?? 1 } : null, + }; +} + export interface Photo { id: string; shot_id: string; @@ -308,6 +321,19 @@ export const api = { }), }), + /** Lights (#208): fixtures of every workspace the user is in; a new one goes to the user's own. */ + listFixtures: () => call<{ fixtures: ServerFixture[] }>("/api/fixtures").then((r) => r.fixtures.map(fixtureFromServer)), + putFixture: (f: Fixture) => + call<{ fixture: ServerFixture }>(`/api/fixtures/${f.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + label: f.label, kind_id: f.kindId, address: f.address, + mesh_network_id: f.mesh?.networkId ?? null, mesh_unicast: f.mesh?.unicast ?? null, mesh_elements: f.mesh?.elements ?? null, + }), + }), + deleteFixture: (id: string) => call<{ deleted: string }>(`/api/fixtures/${id}`, { method: "DELETE" }), + deletePreset: (id: string) => call<{ deleted: string }>(`/api/presets/${id}`, { method: "DELETE" }), /** diff --git a/apps/mobile/src/appState.tsx b/apps/mobile/src/appState.tsx index 024e246..8e601b4 100644 --- a/apps/mobile/src/appState.tsx +++ b/apps/mobile/src/appState.tsx @@ -22,6 +22,9 @@ export type Route = | { name: "step"; day: ShootingDay; shots: Shot[]; index: number } | { name: "rigs" } | { name: "rigEditor"; rigId: string | null } + | { name: "fixtures" } + | { name: "fixtureEditor"; fixtureId: string | null } + | { name: "fixture"; fixtureId: string } | { name: "viewfinderSettings" } | { name: "captureSettings" } | { name: "calibration" } diff --git a/apps/mobile/src/ble/link.ts b/apps/mobile/src/ble/link.ts new file mode 100644 index 0000000..627b3e1 --- /dev/null +++ b/apps/mobile/src/ble/link.ts @@ -0,0 +1,65 @@ +import { BleManager } from "@sfourdrinier/react-native-ble-plx"; +import { fromBase64, toBase64 } from "../fixtures/bytes.ts"; +import type { Link } from "../fixtures/types.ts"; + +/** + * The only file that talks to the Bluetooth library (lights, epic #206): drivers, the hub and + * the tests see `Link`. One manager for the app's lifetime, created on first use so the app does + * not touch Bluetooth until Lights is opened. + */ +let manager: BleManager | null = null; +const ble = () => (manager ??= new BleManager()); + +export type Adapter = "on" | "off" | "unauthorized" | "unsupported" | "unknown"; + +/** Whether Bluetooth can be used right now. */ +export async function adapterState(): Promise { + const s = await ble().state(); + return s === "PoweredOn" ? "on" : s === "PoweredOff" ? "off" : s === "Unauthorized" ? "unauthorized" : s === "Unsupported" ? "unsupported" : "unknown"; +} + +export interface Seen { address: string; name: string | null; rssi: number | null; serviceUUIDs: string[] | null } + +/** Report every advertising device (repeats included) until the returned function is called. */ +export function scan(onSeen: (d: Seen) => void, onError: (e: unknown) => void): () => void { + let stopped = false; + ble().startDeviceScan(null, { allowDuplicates: false }, (err, d) => { + if (stopped) return; + if (err) { onError(err); return; } + if (d) onSeen({ address: d.id, name: d.localName ?? d.name, rssi: d.rssi, serviceUUIDs: d.serviceUUIDs }); + }).catch(onError); + return () => { stopped = true; void ble().stopDeviceScan().catch(() => {}); }; +} + +/** MTU asked for on connect: room for a NEEWER frame or a mesh proxy PDU in one write. */ +const MTU = 247; +const CONNECT_TIMEOUT_MS = 15_000; + +export async function connect(address: string): Promise { + const m = ble(); + const device = await m.connectToDevice(address, { requestMTU: MTU, timeout: CONNECT_TIMEOUT_MS }); + try { + await m.discoverAllServicesAndCharacteristicsForDevice(address); + } catch (e) { + await m.cancelDeviceConnection(address).catch(() => {}); + throw e; + } + return { + address, + mtu: device.mtu || 23, + async write(service, characteristic, bytes, withResponse) { + const v = toBase64(bytes); + if (withResponse) await m.writeCharacteristicWithResponseForDevice(address, service, characteristic, v); + else await m.writeCharacteristicWithoutResponseForDevice(address, service, characteristic, v); + }, + async notify(service, characteristic, cb) { + const sub = m.monitorCharacteristicForDevice(address, service, characteristic, (err, c) => { if (!err && c?.value) cb(fromBase64(c.value)); }); + return () => sub.remove(); + }, + onDisconnect(cb) { + const sub = m.onDeviceDisconnected(address, (err) => cb(err)); + return () => sub.remove(); + }, + async close() { await m.cancelDeviceConnection(address).catch(() => {}); }, + }; +} diff --git a/apps/mobile/src/ble/permissions.ts b/apps/mobile/src/ble/permissions.ts new file mode 100644 index 0000000..d3d2c4b --- /dev/null +++ b/apps/mobile/src/ble/permissions.ts @@ -0,0 +1,14 @@ +import { PermissionsAndroid, Platform } from "react-native"; + +/** + * Ask for what scanning and connecting need. Android 12+ has its own Bluetooth permissions (the + * manifest marks scanning as never used for location); older Android ties scanning to location. + * True when everything was granted. + */ +export async function askBluetooth(): Promise { + if (Platform.OS !== "android") return true; + const P = PermissionsAndroid.PERMISSIONS; + const wanted = Number(Platform.Version) >= 31 ? [P.BLUETOOTH_SCAN, P.BLUETOOTH_CONNECT] : [P.ACCESS_FINE_LOCATION]; + const got = await PermissionsAndroid.requestMultiple(wanted); + return wanted.every((p) => got[p] === PermissionsAndroid.RESULTS.GRANTED); +} diff --git a/apps/mobile/src/fixtures/bytes.test.ts b/apps/mobile/src/fixtures/bytes.test.ts new file mode 100644 index 0000000..5444bbf --- /dev/null +++ b/apps/mobile/src/fixtures/bytes.test.ts @@ -0,0 +1,18 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fromBase64, fromHex, toBase64, toHex } from "./bytes.ts"; + +test("base64 round trips every length and matches Node's encoder", () => { + for (let n = 0; n < 40; n++) { + const b = Uint8Array.from({ length: n }, (_, i) => (i * 37 + n) & 255); + const b64 = toBase64(b); + assert.equal(b64, Buffer.from(b).toString("base64")); + assert.deepEqual(fromBase64(b64), b); + } +}); + +test("hex for the debug log", () => { + assert.equal(toHex(Uint8Array.of(0x78, 0x8d, 0x08, 0)), "78 8d 08 00"); + assert.deepEqual(fromHex("78 8D 08"), Uint8Array.of(0x78, 0x8d, 0x08)); + assert.throws(() => fromHex("7")); +}); diff --git a/apps/mobile/src/fixtures/bytes.ts b/apps/mobile/src/fixtures/bytes.ts new file mode 100644 index 0000000..4dc8804 --- /dev/null +++ b/apps/mobile/src/fixtures/bytes.ts @@ -0,0 +1,34 @@ +/** Byte helpers for the drivers: hex for the debug log, base64 for the Bluetooth library. */ + +export const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(" "); + +export function fromHex(s: string): Uint8Array { + const clean = s.replace(/[^0-9a-f]/gi, ""); + if (clean.length % 2) throw new Error(`odd hex length: ${s}`); + return Uint8Array.from(clean.match(/../g) ?? [], (h) => parseInt(h, 16)); +} + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/** Hand-written: Hermes's atob/btoa work on strings, and this keeps the codecs testable in Node. */ +export function toBase64(b: Uint8Array): string { + let out = ""; + for (let i = 0; i < b.length; i += 3) { + const n = (b[i] << 16) | ((b[i + 1] ?? 0) << 8) | (b[i + 2] ?? 0); + out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + (i + 1 < b.length ? B64[(n >> 6) & 63] : "=") + (i + 2 < b.length ? B64[n & 63] : "="); + } + return out; +} + +export function fromBase64(s: string): Uint8Array { + const clean = s.replace(/[^A-Za-z0-9+/]/g, ""); + const out: number[] = []; + for (let i = 0; i < clean.length; i += 4) { + const c = [0, 1, 2, 3].map((k) => B64.indexOf(clean[i + k] ?? "A")); + const n = (c[0] << 18) | (c[1] << 12) | (c[2] << 6) | c[3]; + out.push((n >> 16) & 255); + if (i + 2 < clean.length) out.push((n >> 8) & 255); + if (i + 3 < clean.length) out.push(n & 255); + } + return Uint8Array.from(out); +} diff --git a/apps/mobile/src/fixtures/hub.ts b/apps/mobile/src/fixtures/hub.ts new file mode 100644 index 0000000..d971e88 --- /dev/null +++ b/apps/mobile/src/fixtures/hub.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import { AppState } from "react-native"; +import { connect } from "../ble/link"; +import { log } from "../log"; +import { Hub, type FixtureStatus, type Holder } from "./hubCore"; +import { getFixtures } from "./library"; +import { driverFor } from "./registry"; +import type { Command } from "./types"; + +/** The app's one hub (fixtures/hubCore.ts) on the real Bluetooth stack and clock. */ +export const hub = new Hub({ + fixtures: getFixtures, + driverFor, + connect, + timers: { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (h) => clearTimeout(h as ReturnType) }, + clock: { now: () => Date.now(), wait: (ms) => new Promise((r) => setTimeout(r, ms)) }, + log, +}); + +let watching = false; +/** Android stops Bluetooth work in the background: close there, reopen on return. Started on first use. */ +function watchAppState() { + if (watching) return; + watching = true; + AppState.addEventListener("change", (s) => { + if (s === "active") hub.resume(); + else if (s === "background") hub.suspend(); + }); +} + +/** Keep the fixtures connected while the calling screen is shown. */ +export function useFixtureLease(ids: string[], holder: Holder = "screen") { + const key = ids.join(","); + useEffect(() => { + watchAppState(); + return hub.acquire(key ? key.split(",") : [], holder); + }, [key, holder]); +} + +export function useFixtureStatus(id: string): FixtureStatus { + const [s, setS] = useState(() => hub.status(id)); + useEffect(() => { + const cb = () => setS(hub.status(id)); + cb(); + return hub.subscribe(cb); + }, [id]); + return s; +} + +/** Queued and coalesced; rejects when the fixture is not connected. */ +export const sendToFixture = (id: string, cmd: Command) => hub.send(id, cmd); + +export function linkLabel(s: FixtureStatus): string { + switch (s.link) { + case "ready": return "Connected"; + case "connecting": return "Connecting…"; + case "retrying": return `Reconnecting (try ${s.attempt + 1})${s.error ? ` · ${s.error}` : ""}`; + case "failed": return s.error ?? "Cannot connect"; + default: return "Not connected"; + } +} diff --git a/apps/mobile/src/fixtures/hubCore.test.ts b/apps/mobile/src/fixtures/hubCore.test.ts new file mode 100644 index 0000000..19c4766 --- /dev/null +++ b/apps/mobile/src/fixtures/hubCore.test.ts @@ -0,0 +1,177 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { GRACE_MS, Hub, RETRY_MS } from "./hubCore.ts"; +import type { Command, Driver, Fixture, Link } from "./types.ts"; + +const settle = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); }; + +/** Timers and clock that move only on advance(). */ +function fakeTime() { + let seq = 0; + const tasks = new Map void }>(); + const t = { + now: 0, + setTimeout: (fn: () => void, ms: number) => { const id = ++seq; tasks.set(id, { at: t.now + ms, fn }); return id; }, + clearTimeout: (h: unknown) => { tasks.delete(h as number); }, + pending: () => [...tasks.values()].map((x) => x.at - t.now).sort((a, b) => a - b), + async advance(ms: number) { + const target = t.now + ms; + for (;;) { + await settle(); + const next = [...tasks.entries()].filter(([, x]) => x.at <= target).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + tasks.delete(next[0]); + t.now = next[1].at; + next[1].fn(); + } + t.now = target; + await settle(); + }, + }; + return t; +} + +class FakeLink implements Link { + mtu = 23; + writes: string[] = []; + closed = false; + private drop = new Set<(e: unknown) => void>(); + readonly address: string; + constructor(address: string) { this.address = address; } + async write(_s: string, _c: string, bytes: Uint8Array) { this.writes.push(Array.from(bytes).join(",")); } + async notify() { return () => {}; } + onDisconnect(cb: (e: unknown) => void) { this.drop.add(cb); return () => { this.drop.delete(cb); }; } + async close() { this.closed = true; } + lose() { for (const cb of [...this.drop]) cb(new Error("link lost")); } +} + +const fixture = (id: string, address: string, kindId = "fake"): Fixture => ({ id, label: id, kindId, address, mesh: null, createdAt: "", synced: true }); + +function world(opts: { fail?: number } = {}) { + const time = fakeTime(); + const links: FakeLink[] = []; + const connects: number[] = []; + let failures = opts.fail ?? 0; + const driver: Driver = { + id: "fake", + linkKey: (f) => f.address, + rate: { minIntervalMs: 0 }, + addressesFor: (key) => [key], + open: async (link) => ({ + send: async (_f, cmd: Command) => { await link.write("s", "c", Uint8Array.of(cmd.op.length), false); }, + close: async () => {}, + }), + }; + const fixtures = [fixture("a", "AA:00:00:00:00:01"), fixture("b", "AA:00:00:00:00:02"), fixture("x", "AA:00:00:00:00:03", "unsupported")]; + const hub = new Hub({ + fixtures: () => fixtures, + driverFor: (k) => (k === "fake" ? driver : undefined), + connect: async (address) => { + connects.push(time.now); + if (failures > 0) { failures--; throw new Error("gatt 133"); } + const l = new FakeLink(address); + links.push(l); + return l; + }, + timers: time, + clock: { now: () => time.now, wait: (ms) => new Promise((r) => time.setTimeout(r, ms)) }, + log: () => {}, + }); + return { hub, time, links, connects }; +} + +test("a lease connects; the link closes a grace period after the last release", async () => { + const { hub, time, links } = world(); + const release = hub.acquire(["a"], "screen"); + await time.advance(0); + assert.equal(hub.status("a").link, "ready"); + release(); + release(); // idempotent + await time.advance(GRACE_MS - 1); + assert.equal(hub.status("a").link, "ready"); + await time.advance(1); + assert.equal(hub.status("a").link, "off"); + assert.equal(links[0].closed, true); +}); + +test("coming back within the grace period keeps the same connection", async () => { + const { hub, time, links } = world(); + hub.acquire(["a"], "screen")(); + await time.advance(GRACE_MS / 2); + const again = hub.acquire(["a"], "screen"); + await time.advance(GRACE_MS * 2); + assert.equal(links.length, 1); + assert.equal(hub.status("a").link, "ready"); + again(); +}); + +test("two holders: the link stays until both let go", async () => { + const { hub, time } = world(); + const screen = hub.acquire(["a", "b"], "screen"); + const sequence = hub.acquire(["a"], "sequence"); + await time.advance(0); + screen(); + await time.advance(GRACE_MS * 2); + assert.equal(hub.status("a").link, "ready"); + assert.equal(hub.status("b").link, "off"); + sequence(); +}); + +test("failed connects retry with the backoff, the last wait repeating", async () => { + const { hub, time, connects } = world({ fail: 7 }); + hub.acquire(["a"], "screen"); + await time.advance(0); + assert.equal(hub.status("a").link, "retrying"); + assert.equal(hub.status("a").error, "gatt 133"); + await time.advance(200_000); + const waits = connects.slice(1).map((t, i) => t - connects[i]); + assert.deepEqual(waits, [...RETRY_MS, 30_000, 30_000]); + assert.equal(hub.status("a").link, "ready"); + assert.equal(hub.status("a").attempt, 0); +}); + +test("a lost link reconnects while held", async () => { + const { hub, time, links } = world(); + hub.acquire(["a"], "screen"); + await time.advance(0); + links[0].lose(); + assert.equal(hub.status("a").link, "retrying"); + await assert.rejects(hub.send("a", { op: "power", on: true }), /not connected/); + await time.advance(RETRY_MS[0]); + assert.equal(hub.status("a").link, "ready"); + assert.equal(links.length, 2); +}); + +test("background closes everything; foreground reopens what is still held", async () => { + const { hub, time, links } = world(); + hub.acquire(["a"], "screen"); + hub.acquire(["b"], "screen")(); + await time.advance(0); + hub.suspend(); + await time.advance(0); + assert.equal(hub.status("a").link, "off"); + assert.ok(links.every((l) => l.closed)); + hub.resume(); + await time.advance(0); + assert.equal(hub.status("a").link, "ready"); + assert.equal(hub.status("b").link, "off"); +}); + +test("sends go through the link and update the known state", async () => { + const { hub, time, links } = world(); + hub.acquire(["a"], "screen"); + await time.advance(0); + await hub.send("a", { op: "intensity", value: 0.4 }); + await hub.send("a", { op: "cct", kelvin: 3200, gm: 0 }); + assert.equal(links[0].writes.length, 2); + assert.deepEqual(hub.status("a").state, { intensity: 0.4, cct: { kelvin: 3200, gm: 0 }, hsi: undefined }); +}); + +test("a kind without a driver says so instead of connecting", async () => { + const { hub, time, connects } = world(); + hub.acquire(["x"], "screen"); + await time.advance(0); + assert.equal(hub.status("x").link, "failed"); + assert.equal(connects.length, 0); + assert.equal(hub.status("nope").error, "unknown fixture"); +}); diff --git a/apps/mobile/src/fixtures/hubCore.ts b/apps/mobile/src/fixtures/hubCore.ts new file mode 100644 index 0000000..ea0b210 --- /dev/null +++ b/apps/mobile/src/fixtures/hubCore.ts @@ -0,0 +1,255 @@ +import type { LightState } from "@openclapper/vocab"; +import { CommandQueue, type Clock } from "./queue.ts"; +import type { Command, Driver, Fixture, Link, Session, Telemetry } from "./types.ts"; + +export type LinkState = "off" | "connecting" | "ready" | "retrying" | "failed"; + +export interface FixtureStatus { + link: LinkState; + /** Failed connection attempts in a row. */ + attempt: number; + error: string | null; + telemetry: Telemetry | null; + /** What the phone last set or the fixture last reported. */ + state: LightState; +} + +/** Who holds a connection open: a screen showing the fixture, or a running sequence (later). */ +export type Holder = "screen" | "sequence"; + +export interface Timers { + setTimeout(fn: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +export interface HubDeps { + fixtures: () => Fixture[]; + driverFor: (kindId: string) => Driver | undefined; + connect: (address: string) => Promise; + timers: Timers; + clock: Clock; + log: (level: "debug" | "info" | "warn", event: string, data?: unknown) => void; +} + +/** Waits between reconnect attempts; the last one repeats while the connection is still wanted. */ +export const RETRY_MS = [1000, 2000, 5000, 15_000, 30_000]; +/** A connection outlives its last lease this long, so going back and forth between screens does not reconnect. */ +export const GRACE_MS = 2000; + +interface LinkRec { + key: string; + driver: Driver; + leases: number; + state: LinkState; + attempt: number; + error: string | null; + link: Link | null; + session: Session | null; + queue: CommandQueue | null; + offDisconnect: (() => void) | null; + timer: unknown; + /** Bumped by every start and stop: an attempt that finishes after it was superseded is dropped. */ + gen: number; +} + +const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); + +/** The state a command leaves a light in, as far as the phone knows. */ +export function applyCommand(s: LightState, cmd: Command): LightState { + switch (cmd.op) { + case "power": return { ...s, power: cmd.on }; + case "intensity": return { ...s, intensity: cmd.value }; + case "cct": return { ...s, cct: { kelvin: cmd.kelvin, gm: cmd.gm }, hsi: undefined }; + case "hsi": return { ...s, hsi: { h: cmd.h, s: cmd.s, i: cmd.i }, cct: undefined, intensity: cmd.i }; + default: return s; + } +} + +/** + * The connection manager (#208). Connections exist only while something holds a lease on them, + * reconnect with backoff while held, close a little after the last lease, and close all while the + * app is in the background (Android stops Bluetooth work there anyway). Writes go through one + * queue per link. Free of React Native so it runs under `node --test`; hub.ts wires it up. + */ +export class Hub { + private links = new Map(); + private telemetry = new Map(); + private states = new Map(); + private listeners = new Set<() => void>(); + private suspended = false; + + private readonly d: HubDeps; + + constructor(deps: HubDeps) { + this.d = deps; + } + + subscribe(cb: () => void): () => void { + this.listeners.add(cb); + return () => { this.listeners.delete(cb); }; + } + + /** Hold the connections the fixtures need; call the result to let go (idempotent). */ + acquire(fixtureIds: string[], holder: Holder): () => void { + const keys: string[] = []; + for (const id of fixtureIds) { + const r = this.resolve(id); + if (!r) continue; + let rec = this.links.get(r.key); + if (!rec) { + rec = { key: r.key, driver: r.driver, leases: 0, state: "off", attempt: 0, error: null, link: null, session: null, queue: null, offDisconnect: null, timer: null, gen: 0 }; + this.links.set(r.key, rec); + } + if (keys.includes(r.key)) continue; + keys.push(r.key); + rec.leases++; + // Open or opening: only cancel a pending close. Otherwise (off, failed, waiting to retry) try now. + if (rec.state === "ready" || rec.state === "connecting") this.clearTimer(rec); + else if (!this.suspended) void this.start(rec); + } + this.d.log("debug", "fixture lease", { holder, links: keys }); + let released = false; + return () => { + if (released) return; + released = true; + for (const key of keys) { + const rec = this.links.get(key); + if (!rec || --rec.leases > 0) continue; + this.clearTimer(rec); + rec.timer = this.d.timers.setTimeout(() => { if (rec.leases === 0) void this.stop(rec); }, GRACE_MS); + } + }; + } + + /** Queued and coalesced per link; rejects when the fixture is not connected. */ + send(fixtureId: string, cmd: Command): Promise { + const f = this.fixture(fixtureId); + const r = f && this.resolve(fixtureId); + const rec = r ? this.links.get(r.key) : undefined; + if (!f || !rec || rec.state !== "ready" || !rec.queue) return Promise.reject(new Error("not connected")); + return rec.queue.push(`${fixtureId}:${cmd.op}`, async () => { + if (!rec.session) throw new Error("not connected"); + await rec.session.send(f, cmd); + this.states.set(fixtureId, applyCommand(this.states.get(fixtureId) ?? {}, cmd)); + this.notify(); + }); + } + + status(fixtureId: string): FixtureStatus { + const base = { telemetry: this.telemetry.get(fixtureId) ?? null, state: this.states.get(fixtureId) ?? {} }; + const f = this.fixture(fixtureId); + if (!f) return { ...base, link: "off", attempt: 0, error: "unknown fixture" }; + const r = this.resolve(fixtureId); + if (!r) return { ...base, link: "failed", attempt: 0, error: "this kind cannot be controlled yet" }; + const rec = this.links.get(r.key); + return rec ? { ...base, link: rec.state, attempt: rec.attempt, error: rec.error } : { ...base, link: "off", attempt: 0, error: null }; + } + + /** The app went to the background: close everything, keep the leases. */ + suspend() { + this.suspended = true; + for (const rec of this.links.values()) void this.stop(rec); + } + + /** Back in the foreground: reopen what is still held. */ + resume() { + this.suspended = false; + for (const rec of this.links.values()) if (rec.leases > 0) void this.start(rec); + } + + private fixture(id: string) { return this.d.fixtures().find((f) => f.id === id); } + + private resolve(id: string): { key: string; driver: Driver } | null { + const f = this.fixture(id); + const driver = f && this.d.driverFor(f.kindId); + return f && driver ? { key: driver.linkKey(f), driver } : null; + } + + private notify() { for (const cb of this.listeners) cb(); } + + private set(rec: LinkRec, patch: Partial>) { + Object.assign(rec, patch); + this.notify(); + } + + private clearTimer(rec: LinkRec) { + if (rec.timer != null) this.d.timers.clearTimeout(rec.timer); + rec.timer = null; + } + + private onLinkFixtures(rec: LinkRec) { + return this.d.fixtures().filter((f) => { const d = this.d.driverFor(f.kindId); return d === rec.driver && d.linkKey(f) === rec.key; }); + } + + private async start(rec: LinkRec) { + const gen = ++rec.gen; + this.clearTimer(rec); + const fixtures = this.onLinkFixtures(rec); + const addresses = rec.driver.addressesFor(rec.key, fixtures); + if (!addresses.length) { this.set(rec, { state: "failed", error: "no address to connect to" }); return; } + this.set(rec, { state: "connecting" }); + let lastError: unknown = null; + for (const address of addresses) { + let link: Link | null = null; + try { + link = await this.d.connect(address); + if (gen !== rec.gen) { void link.close().catch(() => {}); return; } + const session = await rec.driver.open(link, fixtures, (id, t) => this.onTelemetry(id, t)); + if (gen !== rec.gen) { void session.close().catch(() => {}); void link.close().catch(() => {}); return; } + rec.link = link; + rec.session = session; + rec.queue = new CommandQueue(rec.driver.rate.minIntervalMs, this.d.clock); + rec.offDisconnect = link.onDisconnect((e) => this.onDisconnect(rec, gen, e)); + this.d.log("info", "fixture link ready", { link: rec.key, address, mtu: link.mtu }); + this.set(rec, { state: "ready", attempt: 0, error: null }); + return; + } catch (e) { + lastError = e; + if (link) void link.close().catch(() => {}); + if (gen !== rec.gen) return; + } + } + this.retry(rec, lastError); + } + + private retry(rec: LinkRec, error: unknown) { + const attempt = rec.attempt + 1; + this.d.log("warn", "fixture link failed", { link: rec.key, attempt, error: message(error) }); + if (rec.leases === 0 || this.suspended) { this.set(rec, { state: "off", attempt, error: message(error) }); return; } + this.set(rec, { state: "retrying", attempt, error: message(error) }); + const gen = rec.gen; + rec.timer = this.d.timers.setTimeout(() => { if (gen === rec.gen) void this.start(rec); }, RETRY_MS[Math.min(attempt - 1, RETRY_MS.length - 1)]); + } + + private onDisconnect(rec: LinkRec, gen: number, error: unknown) { + if (gen !== rec.gen) return; + this.teardown(rec, new Error("disconnected")); + rec.gen++; + this.retry(rec, error ?? new Error("disconnected")); + } + + private teardown(rec: LinkRec, reason: Error) { + rec.queue?.close(reason); + rec.offDisconnect?.(); + const { session, link } = rec; + Object.assign(rec, { queue: null, offDisconnect: null, session: null, link: null }); + void (async () => { + await session?.close().catch(() => {}); + await link?.close().catch(() => {}); + })(); + } + + private async stop(rec: LinkRec) { + rec.gen++; + this.clearTimer(rec); + if (rec.link || rec.state !== "off") this.d.log("info", "fixture link closed", { link: rec.key }); + this.teardown(rec, new Error("closed")); + this.set(rec, { state: "off", attempt: 0, error: null }); + } + + private onTelemetry(fixtureId: string, t: Telemetry) { + this.telemetry.set(fixtureId, { ...this.telemetry.get(fixtureId), ...t }); + if (t.state) this.states.set(fixtureId, { ...this.states.get(fixtureId), ...t.state }); + this.notify(); + } +} diff --git a/apps/mobile/src/fixtures/library.ts b/apps/mobile/src/fixtures/library.ts new file mode 100644 index 0000000..1e6f916 --- /dev/null +++ b/apps/mobile/src/fixtures/library.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { log } from "../log"; +import { store } from "../storage"; +import type { Fixture } from "./types"; + +/** + * The paired fixtures (#208), shared by Setup → Lights and the hub. Like the rigs: kept on the + * phone so they work offline, pushed to the Worker, and the server list wins after a sync, so + * another phone of the same workspace sees them too (its mesh keys stay on the phone that made them). + */ +let fixtures: Fixture[] = store.loadFixtures(); +const listeners = new Set<() => void>(); + +function commit(next: Fixture[]) { + fixtures = next; + store.saveFixtures(fixtures); + for (const cb of listeners) cb(); +} + +export const getFixtures = () => fixtures; + +export function useFixtures(): Fixture[] { + const [v, setV] = useState(getFixtures); + useEffect(() => { const cb = () => setV(getFixtures()); listeners.add(cb); cb(); return () => { listeners.delete(cb); }; }, []); + return v; +} + +/** Save locally first (usable at once, also offline), then push; an unsynced fixture is pushed again by the next sync. */ +export async function saveFixture(f: Fixture) { + const mine = { ...f, synced: false }; + commit(fixtures.some((x) => x.id === f.id) ? fixtures.map((x) => (x.id === f.id ? mine : x)) : [...fixtures, mine]); + try { + await api.putFixture(mine); + commit(fixtures.map((x) => (x.id === f.id ? { ...x, synced: true } : x))); + } catch (err) { + log("warn", "fixture sync failed; retried on the next sync", { fixture: f.id, error: err }); + } +} + +export async function deleteFixture(id: string) { + commit(fixtures.filter((x) => x.id !== id)); + try { await api.deleteFixture(id); } catch (err) { log("warn", "fixture delete failed", { fixture: id, error: err }); } +} + +let syncing: Promise | null = null; + +/** Push offline edits, then take the server list. Quiet when unreachable. */ +export function syncFixtures(): Promise { + syncing ??= (async () => { + try { + // One refused fixture must not block the others; it stays unsynced and is logged. + for (const f of fixtures.filter((x) => !x.synced)) { + try { await api.putFixture(f); } catch (err) { log("warn", "fixture push refused", { fixture: f.id, error: err }); } + } + const server = await api.listFixtures(); + // A fixture saved while the list was on its way stays. + const unsent = fixtures.filter((x) => !x.synced && !server.some((s) => s.id === x.id)); + commit([...server, ...unsent]); + } catch (err) { + log("info", "fixture sync skipped", { error: err }); + } finally { + syncing = null; + } + })(); + return syncing; +} diff --git a/apps/mobile/src/fixtures/queue.test.ts b/apps/mobile/src/fixtures/queue.test.ts new file mode 100644 index 0000000..059029b --- /dev/null +++ b/apps/mobile/src/fixtures/queue.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CommandQueue } from "./queue.ts"; + +/** A clock that only moves when the queue waits, so intervals are exact. */ +function fakeClock() { + const c = { t: 0, waits: [] as number[], now: () => c.t, wait: async (ms: number) => { c.waits.push(ms); c.t += ms; } }; + return c; +} + +test("one write at a time, at most one per interval", async () => { + const clock = fakeClock(); + const q = new CommandQueue(60, clock); + const sent: [string, number][] = []; + const op = (k: string) => q.push(k, async () => { sent.push([k, clock.t]); }); + await Promise.all([op("a"), op("b"), op("c")]); + assert.deepEqual(sent, [["a", 0], ["b", 60], ["c", 120]]); +}); + +test("a newer command of the same key replaces the queued one and moves to the back", async () => { + const clock = fakeClock(); + const q = new CommandQueue(60, clock); + const sent: string[] = []; + const op = (k: string, v: string) => q.push(k, async () => { sent.push(`${k}=${v}`); }); + const all = [op("power", "on"), op("cct", "3200"), op("power", "off"), op("cct", "5600"), op("intensity", "0.5"), op("cct", "4300")]; + await Promise.all(all); // every caller resolves, including the replaced ones + // "power=on" was already being written when the others arrived. + assert.deepEqual(sent, ["power=on", "power=off", "intensity=0.5", "cct=4300"]); +}); + +test("a slider flood ends with the last value only", async () => { + const clock = fakeClock(); + const q = new CommandQueue(60, clock); + const sent: number[] = []; + const pushes = Array.from({ length: 50 }, (_, i) => q.push("f1:intensity", async () => { sent.push(i); })); + await Promise.all(pushes); + assert.deepEqual(sent, [0, 49]); +}); + +test("a failed write rejects its callers; the queue goes on", async () => { + const q = new CommandQueue(0, fakeClock()); + const bad = q.push("a", async () => { throw new Error("gatt 133"); }); + const good = q.push("b", async () => {}); + await assert.rejects(bad, /gatt 133/); + await good; +}); + +test("close rejects what is queued and everything after", async () => { + const clock = fakeClock(); + const q = new CommandQueue(60, clock); + let release!: () => void; + const first = q.push("a", () => new Promise((r) => { release = r; })); + const second = q.push("b", async () => {}); + q.close(new Error("disconnected")); + release(); + await first; + await assert.rejects(second, /disconnected/); + await assert.rejects(q.push("c", async () => {}), /disconnected/); + assert.equal(q.size, 0); +}); diff --git a/apps/mobile/src/fixtures/queue.ts b/apps/mobile/src/fixtures/queue.ts new file mode 100644 index 0000000..c811c77 --- /dev/null +++ b/apps/mobile/src/fixtures/queue.ts @@ -0,0 +1,73 @@ +/** Time for the queue and the hub; the real one in hub.ts, a fake one in the tests. */ +export interface Clock { + now(): number; + wait(ms: number): Promise; +} + +interface Waiter { resolve: () => void; reject: (e: unknown) => void } +interface Entry { key: string; run: () => Promise; waiters: Waiter[] } + +/** + * The writes of one Bluetooth link, one at a time and at most one per `minIntervalMs`. A slider + * sends far more than a light takes, and writes without response have no flow control, so a newer + * command with the same key (fixture + op) replaces the queued one instead of queueing behind it. + * The replacement moves to the back, so the last thing asked is also the last thing sent: power + * off after a colour change stays off. + */ +export class CommandQueue { + private entries: Entry[] = []; + private running = false; + private last = Number.NEGATIVE_INFINITY; + private closed: Error | null = null; + + private readonly minIntervalMs: number; + private readonly clock: Clock; + + constructor(minIntervalMs: number, clock: Clock) { + this.minIntervalMs = minIntervalMs; + this.clock = clock; + } + + get size() { return this.entries.length; } + + /** Resolves when this command, or the newer one that replaced it, was written. */ + push(key: string, run: () => Promise): Promise { + if (this.closed) return Promise.reject(this.closed); + return new Promise((resolve, reject) => { + const i = this.entries.findIndex((e) => e.key === key); + const waiters = i >= 0 ? this.entries.splice(i, 1)[0].waiters : []; + this.entries.push({ key, run, waiters: [...waiters, { resolve, reject }] }); + void this.pump(); + }); + } + + /** Rejects everything still queued; later pushes reject at once. */ + close(reason: Error) { + this.closed = reason; + const dropped = this.entries; + this.entries = []; + for (const e of dropped) for (const w of e.waiters) w.reject(reason); + } + + private async pump() { + if (this.running) return; + this.running = true; + try { + while (this.entries.length && !this.closed) { + const gap = this.last + this.minIntervalMs - this.clock.now(); + if (gap > 0) await this.clock.wait(gap); + const e = this.entries.shift(); + if (!e || this.closed) break; + this.last = this.clock.now(); + try { + await e.run(); + for (const w of e.waiters) w.resolve(); + } catch (err) { + for (const w of e.waiters) w.reject(err); + } + } + } finally { + this.running = false; + } + } +} diff --git a/apps/mobile/src/fixtures/registry.test.ts b/apps/mobile/src/fixtures/registry.test.ts new file mode 100644 index 0000000..172e673 --- /dev/null +++ b/apps/mobile/src/fixtures/registry.test.ts @@ -0,0 +1,16 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { driverFor, suggestKinds } from "./registry.ts"; + +test("suggestions from the advertisement", () => { + assert.deepEqual(suggestKinds({ name: null, serviceUUIDs: ["00001827-0000-1000-8000-00805F9B34FB"] }), ["aputure-mc"]); + assert.deepEqual(suggestKinds({ name: "NW-20230051", serviceUUIDs: null }), ["neewer-fs230b", "neewer-fs300b", "neewer-ps099s"]); + assert.deepEqual(suggestKinds({ name: "NEEWER FS300B", serviceUUIDs: null })[0], "neewer-fs300b"); + assert.deepEqual(suggestKinds({ name: "NEEWER-PS099S", serviceUUIDs: null })[0], "neewer-ps099s"); + assert.deepEqual(suggestKinds({ name: "Pixel Buds", serviceUUIDs: null }), []); +}); + +test("no driver yet for any kind (the protocols land in #209, #210, #213)", () => { + assert.equal(driverFor("neewer-fs230b"), undefined); + assert.equal(driverFor("nope"), undefined); +}); diff --git a/apps/mobile/src/fixtures/registry.ts b/apps/mobile/src/fixtures/registry.ts new file mode 100644 index 0000000..98237b7 --- /dev/null +++ b/apps/mobile/src/fixtures/registry.ts @@ -0,0 +1,42 @@ +import { FIXTURE_KINDS, fixtureKind } from "@openclapper/vocab"; +import type { Driver } from "./types.ts"; + +/** + * The phone's drivers by id (FixtureKind.driver). Empty until the first protocol lands: NEEWER + * lights (#209), the PS099S battery (#210), Aputure over the mesh (#213). A new model is a kind in + * packages/vocab/src/lighting.ts plus, when its protocol is new, one driver file registered here. + */ +const DRIVERS: Record = {}; + +export const driverFor = (kindId: string): Driver | undefined => { + const kind = fixtureKind(kindId); + return kind ? DRIVERS[kind.driver] : undefined; +}; + +/** What a scan sees of a device. */ +export interface Advert { name: string | null; serviceUUIDs: string[] | null } + +const MESH_PROVISIONING = "00001827-0000-1000-8000-00805f9b34fb"; +const MESH_PROXY = "00001828-0000-1000-8000-00805f9b34fb"; +const NEEWER_SERVICE = "69400001-b5a3-f393-e0a9-e50e24dcca99"; + +const NEEWER_LIGHTS = ["neewer-fs230b", "neewer-fs300b"]; + +/** + * The kinds a scanned device may be, most likely first; empty when nothing points to one. Only a + * suggestion: names vary by firmware, so the person pairing always confirms the model. + */ +export function suggestKinds(a: Advert): string[] { + const name = a.name ?? ""; + const services = (a.serviceUUIDs ?? []).map((s) => s.toLowerCase()); + if (services.includes(MESH_PROVISIONING) || services.includes(MESH_PROXY) || /aputure|sidus/i.test(name)) return ["aputure-mc"]; + if (services.includes(NEEWER_SERVICE) || /neewer|^nw[-_ ]/i.test(name)) { + if (/ps0?99|batt/i.test(name)) return ["neewer-ps099s", ...NEEWER_LIGHTS]; + if (/fs[- ]?300/i.test(name)) return ["neewer-fs300b", "neewer-fs230b", "neewer-ps099s"]; + return [...NEEWER_LIGHTS, "neewer-ps099s"]; + } + return []; +} + +/** Every kind, for picking one by hand. */ +export const ALL_KINDS = FIXTURE_KINDS; diff --git a/apps/mobile/src/fixtures/types.ts b/apps/mobile/src/fixtures/types.ts new file mode 100644 index 0000000..d276a58 --- /dev/null +++ b/apps/mobile/src/fixtures/types.ts @@ -0,0 +1,70 @@ +import type { Curve, LightState } from "@openclapper/vocab"; + +/** + * Lights (epic #206): the phone's side. "Fixture" because "device" already means this phone + * (src/device.ts). A fixture is a light or the battery; its kind (packages/vocab/src/lighting.ts) + * says what it can do, its driver how to reach it. + */ +export interface Fixture { + id: string; + label: string; + kindId: string; + /** Bluetooth address as Android reports it; NEEWER frames carry it too. */ + address: string; + /** Set once a mesh fixture is provisioned (#212). */ + mesh: { networkId: string; unicast: number; elements: number } | null; + createdAt: string; + /** false until the Worker has acknowledged the PUT. */ + synced: boolean; +} + +/** One change to a fixture, in normalised units: intensity, saturation 0..1, hue 0..360, kelvin. */ +export type Command = + | { op: "power"; on: boolean } + | { op: "intensity"; value: number } + | { op: "cct"; kelvin: number; gm: number } + | { op: "hsi"; h: number; s: number; i: number } + | { op: "curve"; curve: Curve } + | { op: "status" }; + +/** What a fixture reported last; `state` is what a light says it shows. */ +export interface Telemetry { + at: number; + battery?: number; + volts?: number; + watts?: number; + minutesLeft?: number; + state?: LightState; +} + +/** One open Bluetooth connection, as ble/link.ts gives it to a driver (a fake one in tests). */ +export interface Link { + address: string; + mtu: number; + write(service: string, characteristic: string, bytes: Uint8Array, withResponse: boolean): Promise; + /** Resolves once notifications are on; the function turns them off. */ + notify(service: string, characteristic: string, cb: (bytes: Uint8Array) => void): Promise<() => void>; + onDisconnect(cb: (error: unknown) => void): () => void; + close(): Promise; +} + +/** A driver's live handle on one link. */ +export interface Session { + send(fixture: Fixture, cmd: Command): Promise; + close(): Promise; +} + +/** How a family of fixtures is reached: one file per driver in fixtures/drivers (#209 on). */ +export interface Driver { + id: string; + /** + * Which connection serves the fixture: its own address (GATT), or one per mesh network + * ("mesh:"), shared by every light of that network through one proxy node. + */ + linkKey(fixture: Fixture): string; + /** Pause between two writes on one link; a newer command of the same op replaces a queued one. */ + rate: { minIntervalMs: number }; + /** Addresses to try for a link, best first (a mesh link can use any node of its network). */ + addressesFor(linkKey: string, fixtures: Fixture[]): string[]; + open(link: Link, fixtures: Fixture[], sink: (fixtureId: string, t: Telemetry) => void): Promise; +} diff --git a/apps/mobile/src/screens/Lights.tsx b/apps/mobile/src/screens/Lights.tsx new file mode 100644 index 0000000..af3beb7 --- /dev/null +++ b/apps/mobile/src/screens/Lights.tsx @@ -0,0 +1,209 @@ +import { useEffect, useRef, useState } from "react"; +import { Text } from "react-native"; +import * as Crypto from "expo-crypto"; +import { fixtureKind, fixtureKindName, FIXTURE_LABEL_MAX, type FixtureKind } from "@openclapper/vocab"; +import { useApp } from "../appState"; +import { adapterState, scan, type Seen } from "../ble/link"; +import { askBluetooth } from "../ble/permissions"; +import { linkLabel, useFixtureLease, useFixtureStatus } from "../fixtures/hub"; +import { deleteFixture, getFixtures, saveFixture, syncFixtures, useFixtures } from "../fixtures/library"; +import { ALL_KINDS, suggestKinds } from "../fixtures/registry"; +import type { Fixture } from "../fixtures/types"; +import { log } from "../log"; +import { Banner, Block, Button, confirm, Empty, ErrorText, FieldRow, Hint, Input, ListRow, makeStyles, num, PushScreen, SectionLabel, SideBySide, toast, type } from "../ui"; +import { OptionSheet } from "../components/OptionSheet"; + +const kindIcon = (k: FixtureKind | undefined) => (k && !k.caps.power ? "battery-high" : "lightbulb-outline"); +const kindName = (id: string) => { const k = fixtureKind(id); return k ? fixtureKindName(k) : `Unknown kind (${id})`; }; + +/** What a kind can be asked to do, in words. */ +function capsLine(k: FixtureKind): string { + const c = k.caps; + return [ + c.intensity && "intensity", + c.cct && `white ${c.cct.min}–${c.cct.max} K${c.cct.gm ? " + green/magenta" : ""}`, + c.hsi && "colour", + c.curves && "dimming curves", + c.telemetry.length > 0 && "charge, volts, watts, time left", + ].filter(Boolean).join(" · "); +} + +function fixtureMeta(f: Fixture): string { + if (!f.synced) return `${kindName(f.kindId)} · not synced yet · saved on the phone`; + const k = fixtureKind(f.kindId); + const mesh = k?.transport === "mesh" ? (f.mesh ? ` · mesh ${f.mesh.unicast}` : " · not on a mesh yet") : ""; + return `${kindName(f.kindId)}${mesh}`; +} + +/** Setup → Lights: every paired light and battery; tap = control, Add light = scan and pair. */ +export function FixtureList() { + const app = useApp(); + const fixtures = useFixtures(); + useEffect(() => { void syncFixtures(); }, []); + return ( + app.push({ name: "fixtureEditor", fixtureId: null })} />}> + {fixtures.length === 0 && } + {fixtures.map((f) => ( + app.push({ name: "fixture", fixtureId: f.id })} onLongPress={() => app.push({ name: "fixtureEditor", fixtureId: f.id })} /> + ))} + + ); +} + +/** How long one scan runs; lights advertise every second or so. */ +const SCAN_MS = 10_000; + +interface Found extends Seen { kinds: string[] } +type ScanState = { phase: "idle" | "scanning" | "done" } | { phase: "error"; message: string }; + +/** Scan nearby; the list keeps the strongest signal per device, likely fixtures first. */ +function useScan(enabled: boolean) { + const [state, setState] = useState({ phase: "idle" }); + const [found, setFound] = useState([]); + const stop = useRef<(() => void) | null>(null); + + const run = async () => { + stop.current?.(); + setFound([]); + setState({ phase: "scanning" }); + if (!(await askBluetooth())) { setState({ phase: "error", message: "Bluetooth permission denied. Allow “Nearby devices” for OpenClapper in Android settings." }); return; } + const adapter = await adapterState().catch(() => "unknown" as const); + if (adapter !== "on") { setState({ phase: "error", message: adapter === "off" ? "Bluetooth is off. Switch it on and scan again." : `Bluetooth is not available (${adapter}).` }); return; } + log("info", "fixture scan started"); + const end = setTimeout(() => { stop.current?.(); stop.current = null; setState({ phase: "done" }); }, SCAN_MS); + const halt = scan( + (d) => setFound((cur) => { + const kinds = suggestKinds(d); + if (!d.name && !kinds.length) return cur; + const old = cur.find((x) => x.address === d.address); + const next = { ...d, name: d.name ?? old?.name ?? null, kinds: kinds.length ? kinds : old?.kinds ?? [] }; + return old ? cur.map((x) => (x.address === d.address ? next : x)) : [...cur, next]; + }), + (e) => { log("warn", "fixture scan failed", e); setState({ phase: "error", message: `Scan failed: ${e instanceof Error ? e.message : String(e)}` }); }, + ); + stop.current = () => { clearTimeout(end); halt(); }; + }; + + useEffect(() => { + if (enabled) void run(); + return () => { stop.current?.(); stop.current = null; }; + }, [enabled]); + + const sorted = [...found].sort((a, b) => Number(b.kinds.length > 0) - Number(a.kinds.length > 0) || (b.rssi ?? -999) - (a.rssi ?? -999)); + return { state, found: sorted, again: () => void run() }; +} + +/** Pair a new fixture (scan, pick, confirm the model) or edit one: label, model, delete. */ +export function FixtureEditor({ fixtureId }: { fixtureId: string | null }) { + const s = useStyles(); + const app = useApp(); + const existing = fixtureId ? getFixtures().find((f) => f.id === fixtureId) ?? null : null; + const paired = useFixtures(); + const { state, found, again } = useScan(!existing); + const [address, setAddress] = useState(existing?.address ?? null); + const [kindId, setKindId] = useState(existing?.kindId ?? null); + const [label, setLabel] = useState(existing?.label ?? ""); + const [sheet, setSheet] = useState(false); + + const kind = kindId ? fixtureKind(kindId) : undefined; + const defaultLabel = kind ? `${kind.model} ${paired.filter((f) => f.kindId === kind.id && f.id !== existing?.id).length + 1}` : ""; + const err = !address ? "Pick a device from the list." : !kind ? "Pick the model." : null; + + const pick = (d: Found) => { + setAddress(d.address); + if (d.kinds[0] && (!kindId || !d.kinds.includes(kindId))) setKindId(d.kinds[0]); + }; + + const save = () => { + if (err || !address || !kind) return; + const f: Fixture = { + id: existing?.id ?? Crypto.randomUUID(), + label: label.trim() || defaultLabel, + kindId: kind.id, + address, + // A changed model drops a mesh pairing that belonged to the old one. + mesh: existing && existing.kindId === kind.id ? existing.mesh : null, + createdAt: existing?.createdAt ?? new Date().toISOString(), + synced: false, + }; + void saveFixture(f); + toast(existing ? `“${f.label}” saved` : `“${f.label}” paired`); + app.pop(); + }; + + const remove = async () => { + if (!existing) return; + const ok = await confirm({ title: `Delete “${existing.label}”?`, body: "Removes it from this phone and the server. The light itself is not changed.", confirmLabel: "Delete", danger: true }); + if (ok === true) { void deleteFixture(existing.id); toast("Deleted", "neutral"); app.pop(); } + }; + + return ( + +