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 (
+
+
+
+ }>
+ {!existing && (
+ <>
+
+ Nearby
+ Switch the light on and make sure no other phone is connected to it (close the maker's app).
+ {state.phase === "error" && {state.message}}
+
+ {state.phase === "scanning" && found.length === 0 && }
+ {found.map((d) => {
+ const taken = paired.find((f) => f.address === d.address);
+ return (
+ pick(d)} />
+ );
+ })}
+ {state.phase === "done" && found.length === 0 && }
+ {state.phase !== "scanning" && }
+ >
+ )}
+
+ Light
+
+ setSheet(true)} />
+ {kind && {capsLine(kind)}}
+
+ Name
+
+ {address && {address}}
+ {kind?.transport === "mesh" && {existing?.mesh ? `On mesh network as address ${existing.mesh.unicast}.` : "Not on a mesh network yet: pairing Aputure lights needs mesh support, which comes in a later update."}}
+
+ {existing && }
+ ({ id: k.id, label: fixtureKindName(k), meta: capsLine(k) }))} value={kindId ? [kindId] : []} list
+ onChange={(ids) => ids[0] && setKindId(ids[0])} onClose={() => setSheet(false)} />
+
+ );
+}
+
+/** One fixture: its connection, and (from #209 on) its controls. */
+export function FixtureControl({ fixtureId }: { fixtureId: string }) {
+ const app = useApp();
+ const fixtures = useFixtures();
+ const f = fixtures.find((x) => x.id === fixtureId);
+ useFixtureLease(f ? [f.id] : []);
+ const status = useFixtureStatus(fixtureId);
+ if (!f) return ;
+ const kind = fixtureKind(f.kindId);
+ return (
+ app.push({ name: "fixtureEditor", fixtureId: f.id })} />}>
+
+
+ {kind && {capsLine(kind)}}
+ {status.link === "failed" && The controls for this model are not built yet; pairing and syncing already work.}
+
+
+ );
+}
+
+const useStyles = makeStyles((c) => ({
+ address: { ...type("caption"), color: c.textDim, ...num },
+}));
diff --git a/apps/mobile/src/screens/Setup.tsx b/apps/mobile/src/screens/Setup.tsx
index 08d62c3..fa01b07 100644
--- a/apps/mobile/src/screens/Setup.tsx
+++ b/apps/mobile/src/screens/Setup.tsx
@@ -5,6 +5,7 @@ import { isSignedIn, onAuthChange, signedInEmail } from "../auth";
import { API_URL, isConfigured } from "../config";
import { isLogging, logCount } from "../log";
import { offlineProject } from "../offline";
+import { useFixtures } from "../fixtures/library";
import { useRigs } from "../rigs";
import { useSync } from "../sync";
import { AppHeader, Banner, Button, ListRow, makeStyles, num, SectionLabel, Seg, SyncCard, type } from "../ui";
@@ -31,6 +32,7 @@ export function Setup() {
const { settings: st } = app;
const sync = useSync();
const { rigs, active } = useRigs();
+ const fixtures = useFixtures();
const [email, setEmail] = useState(() => (isSignedIn() ? signedInEmail() : null));
useEffect(() => onAuthChange(() => setEmail(isSignedIn() ? signedInEmail() : null)), []);
const signedIn = !!email;
@@ -55,6 +57,7 @@ export function Setup() {
{!signedIn && isConfigured && app.push({ name: "uploads" })} />}
app.push({ name: "rigs" })} />
+ app.push({ name: "fixtures" })} />
app.push({ name: "viewfinderSettings" })} />
app.push({ name: "captureSettings" })} />
app.push({ name: "offline" })} />
diff --git a/apps/mobile/src/storage.ts b/apps/mobile/src/storage.ts
index c3b5d3f..8d5dd7c 100644
--- a/apps/mobile/src/storage.ts
+++ b/apps/mobile/src/storage.ts
@@ -3,6 +3,7 @@ import { kv as Storage } from "./kv";
import { DEFAULT_SETTINGS } from "./defaults";
import type { ZoomRange } from "./zoom";
import type { FieldDef, FieldDefinition } from "@openclapper/vocab";
+import type { Fixture } from "./fixtures/types";
import type { CaptureDraft, LocationEntry, PendingEdit, PendingUpload, Preset, ProjectEntry, Settings, ShotTags, SpotEntry, UploadRecord, LayoutEntry, PendingLayout } from "./types";
export { DEFAULT_SETTINGS };
@@ -29,6 +30,8 @@ const KEYS = {
lastTags: "lastTags.v1",
pendingEdits: "pendingEdits.v1",
uploadHistory: "uploadHistory.v1",
+ // Lights (#208); the mesh keys are not here but in the secure store.
+ fixtures: "fixtures.v1",
} as const;
/** How long uploaded shots stay listed on the Uploads screen. */
@@ -81,6 +84,9 @@ export const store = {
readArray(KEYS.presets).map((p) => ({ ...p, cameraId: p.cameraId ?? null, formatId: p.formatId ?? null, lensMinMm: p.lensMinMm ?? null, lensMaxMm: p.lensMaxMm ?? null })),
savePresets: (p: Preset[]) => write(KEYS.presets, p),
+ loadFixtures: (): Fixture[] => readArray(KEYS.fixtures),
+ saveFixtures: (f: Fixture[]) => write(KEYS.fixtures, f),
+
loadActivePresetId: (): string | null => Storage.getItemSync(KEYS.activePresetId),
saveActivePresetId: (id: string | null) =>
id ? Storage.setItemSync(KEYS.activePresetId, id) : Storage.removeItemSync(KEYS.activePresetId),
diff --git a/apps/mobile/src/uploads.ts b/apps/mobile/src/uploads.ts
index 101b308..ba43430 100644
--- a/apps/mobile/src/uploads.ts
+++ b/apps/mobile/src/uploads.ts
@@ -3,6 +3,7 @@ import { dataDir } from "./kv";
import { api, ApiError, type Shot } from "./api";
import { signedInUserId } from "./auth";
import { syncDevice } from "./device";
+import { syncFixtures } from "./fixtures/library";
import { refreshLayouts } from "./layouts";
import { flushEdits, remapEdits } from "./localShots";
import { log } from "./log";
@@ -155,6 +156,7 @@ export function flush(opts: { includeStuck?: boolean } = {}): Promise {
+ 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/README.md b/docs/README.md
index 6d64591..1cf56e4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -40,6 +40,7 @@ Reference documentation for the whole app. Start with [architecture](architectur
| [features/files.md](features/files.md) | Files on the desktop: folders, the Desktop folder, the Files app, desktop icons and selection, the recycle bin |
| [features/desktop.md](features/desktop.md) | The Studio desktop: startup and projects, windows and the side-by-side split, workspaces and task view, taskbar and pins, launcher, the apps, URL and keys, the Gallery (grid/list/map, filters, views) |
| [shortcuts.md](shortcuts.md) | Every Studio keyboard shortcut, generated from `apps/studio/src/shortcuts.ts` |
+| [features/lights.md](features/lights.md) | Lights: pairing NEEWER and Aputure fixtures and the NEEWER battery over Bluetooth, the connection manager, the synced list (epic #206) |
| [features/debug-log.md](features/debug-log.md) | The phone's background debug log |
## History
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/docs/development.md b/docs/development.md
index 24c0231..23bfcb4 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -30,7 +30,7 @@ pnpm test # unit and route tests (docs/testing.md):
# tenancy on every route, uploads, recycle bin, images, sign-in
# pnpm -C apps/admin test admin plane: Access JWT, local bypass, account actions
# pnpm -C apps/studio test shortcut registry: collisions, docs/shortcuts.md
- # pnpm -C apps/mobile test calibration, EXIF, framing, lens, zoom, layouts
+ # pnpm -C apps/mobile test calibration, EXIF, framing, lens, zoom, layouts, lights
pnpm build # scripts/build-check.sh, dry runs only, nothing is deployed:
# vite build in apps/studio and apps/admin
# scripts/csp-hash.mjs --check: each _headers CSP still allows its index.html inline script
diff --git a/docs/features/lights.md b/docs/features/lights.md
new file mode 100644
index 0000000..19a2e93
--- /dev/null
+++ b/docs/features/lights.md
@@ -0,0 +1,50 @@
+# Lights
+
+Control film lights and read a battery from the phone, so one app drives every fixture on set and, later, keyframed sequences can drive them together (epic [#206](https://github.com/OpenClapper-dev/core/issues/206)). Setup → **Lights** pairs and lists them.
+
+First fixtures:
+
+| Kind | How it is reached | Can do |
+| --- | --- | --- |
+| NEEWER FS230B, FS300B | Bluetooth LE, its own connection (GATT) | Power, intensity, white 2700–6500 K, dimming curves |
+| NEEWER PS099S (V-mount battery) | Bluetooth LE, its own connection | Charge, volts, watts, time left (read-only) |
+| Aputure MC | Bluetooth Mesh the phone provisions (one connection per network) | Power, intensity, white 3200–6500 K + green/magenta, colour, dimming curves |
+
+**Status (#208):** pairing, the synced list, the connection manager and the screens are built. No driver is built yet, so no fixture can be controlled: a light's screen says "the controls for this model are not built yet". The protocols come in [#209](https://github.com/OpenClapper-dev/core/issues/209) (NEEWER lights), [#210](https://github.com/OpenClapper-dev/core/issues/210) (battery), [#211](https://github.com/OpenClapper-dev/core/issues/211)–[#213](https://github.com/OpenClapper-dev/core/issues/213) (mesh and Aputure).
+
+## Kinds (`packages/vocab/src/lighting.ts`)
+
+`FIXTURE_KINDS` lists every supported model with its transport, its driver id and its capabilities (power, intensity, white range with green/magenta, colour, dimming curves, telemetry). Hardcoded on purpose: a new model is one entry there and, when its protocol is new, one driver on the phone. Kind ids are stored with each fixture and never change. `CURVES` are the four dimming curves both makers' apps offer (linear, exponential, logarithmic, S); the sequence editor will use them as easing too.
+
+## On the phone (`apps/mobile/src/fixtures/`, `src/ble/`)
+
+- **`types.ts`**: `Fixture` (label, kind, Bluetooth address, mesh address once provisioned), `Command` (power, intensity, white, colour, curve, status, in normalised units), `Telemetry`, and the three seams a driver plugs into: `Link` (one open connection), `Driver` (how a family is reached: which connection serves a fixture, the pause between writes, how to open a session) and `Session` (sends commands).
+- **`registry.ts`**: drivers by id (empty until #209), and `suggestKinds`, which guesses the model from a scanned name or service (NEEWER's service or name, the mesh services for Aputure). Only a guess: the person pairing confirms the model.
+- **`hubCore.ts`**: the connection manager, free of React Native and unit-tested.
+ - A screen (or, later, a running sequence) holds a **lease** on the fixtures it shows. Connections exist only while leased, and close 2 s after the last lease, so moving between screens does not reconnect.
+ - A failed or lost connection retries after 1, 2, 5, 15, then every 30 s while it is still leased.
+ - In the background every connection closes (Android stops Bluetooth work there); back in the foreground the leased ones reopen.
+ - One fixture per Bluetooth address for NEEWER; one connection per mesh network for Aputure, shared by all its lights.
+- **`queue.ts`**: the writes of one connection, one at a time with the driver's pause between them. A newer command of the same kind for the same fixture replaces a queued one and moves to the back: a slider drag sends only what the light can take, and the last thing asked is the last thing sent.
+- **`hub.ts`**: the app's hub on the real Bluetooth stack, `useFixtureLease`, `useFixtureStatus`, `sendToFixture`.
+- **`library.ts`**: the paired fixtures, kept on the phone and synced like the rigs (save locally, push, the server list wins; also on every flush of the upload queue).
+- **`ble/link.ts`** is the only file that uses the Bluetooth library ([`@sfourdrinier/react-native-ble-plx`](https://github.com/sfourdrinier/react-native-ble-plx), a maintained fork for Expo SDK 57 / React Native 0.86). It asks for an MTU of 247 and gives up on a connection after 15 s. **`ble/permissions.ts`** asks for "Nearby devices" (Android 12+) or location (older Android).
+- The manifest declares Bluetooth scanning as never used for location; the app's own location permission (GPS) is unchanged.
+
+## Screens (`apps/mobile/src/screens/Lights.tsx`)
+
+- **Lights** (Setup → Lights): every fixture with its model and sync state. Tap opens it, long press edits it, **Add light** pairs a new one.
+- **Add light**: scans for 10 s (Scan again repeats). Likely fixtures come first, with the model they look like; devices already paired are greyed out. Pick one, confirm the model, name it (default: the model and a number), **Pair**.
+- **Edit light**: name and model; delete (only from the phone and the server; the light itself is not changed).
+- **A light**: its connection state, held open while the screen is shown, and what the model can do. The controls arrive with each driver.
+
+## On the server
+
+`fixtures` and `mesh_networks` ([data model](../data-model.md#tables), [API](../api.md)) belong to a workspace and are shared by its projects, like rigs. Pairing is checked strictly: the kind must be known, the address must look like one, mesh fields only on a mesh kind and only with a network of the same workspace. **Mesh keys are never sent to the server**: they stay in the phone's secure store, so a mesh light synced to a second phone cannot be controlled there until keys can be moved between phones.
+
+## Not done
+
+- Every driver (see Status above).
+- The keyframed sequence editor (a later epic): it will hold leases as `"sequence"` and send through the same queues.
+- Sequences in the background: Android stops Bluetooth work there, so a running sequence will need the screen on.
+- Deleting a fixture while offline: it comes back with the next sync (the rigs behave the same).
diff --git a/docs/features/phone-app.md b/docs/features/phone-app.md
index da6341b..f2b2fb7 100644
--- a/docs/features/phone-app.md
+++ b/docs/features/phone-app.md
@@ -12,7 +12,7 @@ One app, four installs side by side, each with its own package id, storage and i
- **Gates**, full screen, before the tabs (`screens/Gates.tsx`):
- **Sign in**: on launch without a session, and whenever a request finds it gone. "Sign in" opens the email and password form ([accounts](accounts.md)); the eye button beside the password shows it; a wrong password, a blocked account or a web page instead of the server (a Wi-Fi login) each say so. "Continue without signing in" keeps shots on the phone.
- **Pick a project**: when none is active, or the active one no longer exists. "New project" works offline.
-- **Pushed screens** replace the tab (the tab bar is hidden, the tab stays mounted underneath). The stack is `Route` in `appState.tsx`: Uploads, Offline, Shot details, Correct position, the focused map, Step-through, Rigs & lenses, the Rig editor and the Setup sub-screens. Android back pops one; on a tab it returns to Shoot first. There is no navigation library.
+- **Pushed screens** replace the tab (the tab bar is hidden, the tab stays mounted underneath). The stack is `Route` in `appState.tsx`: Uploads, Offline, Shot details, Correct position, the focused map, Step-through, Rigs & lenses, the Rig editor, Lights (list, add/edit, one light) and the Setup sub-screens. Android back pops one; on a tab it returns to Shoot first. There is no navigation library.
- **Sheet vs screen**: a sheet for a single choice that returns you where you were (project, rig pick, lens, tag fields). A full screen for anything with its own photo or map.
- **AppHeader** (`ui/chrome.tsx`), 64 dp, on every screen except Shoot:
- Without a title: the **project pill** (tap opens the Project sheet; an amber "Pick a project" when none) and the **sync icon** (tap opens Uploads).
@@ -30,7 +30,7 @@ One app, four installs side by side, each with its own package id, storage and i
| History | `screens/History.tsx`, `components/ShotParts.tsx`, `EditTagsSheet.tsx` | The project's shots one at a time, or only the unreviewed ones ([review](review-and-tagging.md#history-on-the-phone)) |
| Shots | `screens/Shots.tsx`, `screens/ShotDetails.tsx`, `components/LeafletView.tsx`, `mapHtml.ts` | Gallery and Map merged: one filter (All · states · Queued), Grid ⇄ Map; Shot details; [Correct position](positions.md) |
| Day | `screens/Day.tsx`, `screens/StepThrough.tsx` | [Day mode and offline days](day-mode-offline.md) |
-| Setup | `screens/Setup.tsx`, `screens/SetupScreens.tsx`, `screens/Rigs.tsx`, `screens/Uploads.tsx` | Hub, see below |
+| Setup | `screens/Setup.tsx`, `screens/SetupScreens.tsx`, `screens/Rigs.tsx`, `screens/Lights.tsx`, `screens/Uploads.tsx` | Hub, see below |
## Setup hub
@@ -40,6 +40,7 @@ One app, four installs side by side, each with its own package id, storage and i
- Rows, each with its current value; every row opens a screen, and settings save on change:
- **Project**: opens the Project sheet.
- **Rigs & lenses**: the rig list; tap a rig to edit it, "New rig" is pinned.
+ - **Lights**: paired lights and batteries; tap one to open it, "Add light" scans and pairs ([lights](lights.md)).
- **Viewfinder**: the **Layout** ([viewfinder layouts](viewfinder-layouts.md)) and its look and control options, which belong to the layout (issue #68): a change here edits the layout (saved on the phone, sent with the next flush; the Studio sees it). With the built-in **Default** they are greyed out, with **Make a copy** ("My layout", selected at once). A pinned preview (the latest shot, or black, with the current frame, blackout, human frame and HUD chips) above:
- **Phone lenses**: All · Pick · Fixed ([capture](capture.md#camera-react-native-vision-camera-issue-26))
- one switch per HUD chip (Project, Rig and lens, FOV, GPS accuracy, Warnings)
diff --git a/docs/known-issues.md b/docs/known-issues.md
index 5213499..9069ff5 100644
--- a/docs/known-issues.md
+++ b/docs/known-issues.md
@@ -4,6 +4,7 @@ Things that may go wrong but are not fixed (or not verified on a device) yet. Ad
| Since | Area | What can happen | Why it is left | Where |
| --- | --- | --- | --- | --- |
+| 2026-10-23 (#208) | Phone · Lights | Not yet run on a phone: the Bluetooth permission prompt (Android 12+ and older), scanning, pairing, the sync of the list, and that GPS still works with the Bluetooth plugin's extra location entries in the manifest (they apply only up to Android 11; the app's own entries stay). | Owner tests on the next dev build. | `ble/link.ts`, `ble/permissions.ts`, `screens/Lights.tsx`, `app.json` |
| 2026-10-22 (deploy#5) | Deploy · Clone | The first real clone (production → staging) showed three things. **R2**: rclone's first pass fails every object with `NotImplemented` and its automatic second pass copies them all (`rclone check`: 0 differences); the cause is not found. rclone adds its own `mtime`/`btime` custom metadata on the copies, so staging's objects carry two keys production's do not. Production's objects carried no custom metadata at all, lost in the bucket copy of the rename on 2026-10-20: the admin board's Object metadata repair (#255) writes it again, and does the same for staging after a recreate. **D1**: fixed, a remote import does not keep one transaction for the whole file, so the dump is now ordered parents first (see `prepareDump`); verified on Cloudflare: 29 tables, 456 rows, schema equal, no foreign-key violations. A running export blocks other requests to the source database (seconds at alpha size). | R2 works as is; the metadata keys are not read by the app | `scripts/clone-instance.mjs`, `.github/workflows/clone-instance.yml` |
| 2026-10-21 (#223) | Phone · Expo packages | The Expo packages were aligned with SDK 57 (expo 57.0.28, expo-modules-core 57.0.22, expo-dev-client, expo-image, expo-image-manipulator, expo-location, expo-sqlite, expo-file-system, expo-secure-store and more), and react-native-webview went from 13.17.0 down to the SDK's 13.16.1. These are native modules: the dev build was not rebuilt and nothing was run on a phone (camera and capture, uploads, the WebView maps, offline storage, the dev launcher). `expo-doctor` still fails on expo ~57.0.30 and expo-constants ~57.0.22, which are newer than the 7-day cooldown, so its CI step stays advisory. | Needs `make dev-build` and a phone test. Making expo-doctor blocking waits for the cooldown (expo 57.0.30 clears it on 2026-10-22). | `apps/mobile/package.json`, `.github/workflows/ci.yml` |
| 2026-10-21 (#224) | Worker · uploads | A sequence longer than 200 photos is refused (`413`) for the part that crosses the limit: the phone parks it as stuck with its files, nothing is lost, but it cannot be uploaded as one shot. An upload without `content-length` (chunked) is read before the size is known; the per-file 3 MB limit still holds. Not run against the phone: the phone's request is expected to carry `content-length`. | 200 is far above any sequence so far; a split would need phone work. | `shots.ts`, `images.ts`, mobile `uploads.ts` |
diff --git a/docs/testing.md b/docs/testing.md
index 50ecfac..65b0eba 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -11,7 +11,7 @@ What runs where, and how to add a test. All of it runs in `make check` (`pnpm te
| `apps/worker` project `routes` (`test/routes/`) | Vitest + `@cloudflare/vitest-plugin` | workerd (Miniflare), local D1 + R2 | Requests through the Worker's own `fetch` and `scheduled`: tenancy on every route, uploads, name clashes, recycle bin, image proxy, sessions, response headers and the CSRF guard |
| `apps/admin` (`test/`) | Vitest + `@cloudflare/vitest-plugin` | workerd, the app's schema | The admin plane: the Access JWT check, the local bypass and its hostname guard, account actions, the audit trail |
| `apps/studio` | `node --test` | Node | The shortcut registry |
-| `apps/mobile` | `node --test` | Node | Calibration, EXIF, framing, lens, zoom, layouts |
+| `apps/mobile` | `node --test` | Node | Calibration, EXIF, framing, lens, zoom, layouts; lights: the command queue, the connection manager (fake link and timers), scan suggestions, byte helpers (`src/**/*.test.ts`) |
| End-to-end | | | Not yet (#222) |
`pnpm -C apps/worker test` runs both Worker projects; `npx vitest run --project routes` (in `apps/worker`) only the route tests, `npx vitest run test/routes/trash.test.ts` one file, `npx vitest` watches.
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);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 278fda5..4406cf8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,9 @@ importers:
'@openclapper/vocab':
specifier: workspace:*
version: link:../../packages/vocab
+ '@sfourdrinier/react-native-ble-plx':
+ specifier: 3.9.3
+ version: 3.9.3(react-native@0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)
expo:
specifier: ~57.0.28
version: 57.0.28(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(react-native-webview@13.16.1(react-native@0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@6.0.3)
@@ -606,312 +609,156 @@ packages:
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
- '@esbuild/aix-ppc64@0.28.1':
- resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [aix]
-
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
- '@esbuild/android-arm64@0.28.1':
- resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [android]
-
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
- '@esbuild/android-arm@0.28.1':
- resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [android]
-
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
- '@esbuild/android-x64@0.28.1':
- resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [android]
-
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
- '@esbuild/darwin-arm64@0.28.1':
- resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [darwin]
-
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
- '@esbuild/darwin-x64@0.28.1':
- resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [darwin]
-
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
- '@esbuild/freebsd-arm64@0.28.1':
- resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [freebsd]
-
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
- '@esbuild/freebsd-x64@0.28.1':
- resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [freebsd]
-
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
- '@esbuild/linux-arm64@0.28.1':
- resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [linux]
-
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
- '@esbuild/linux-arm@0.28.1':
- resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
- engines: {node: '>=18'}
- cpu: [arm]
- os: [linux]
-
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
- '@esbuild/linux-ia32@0.28.1':
- resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [linux]
-
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
- '@esbuild/linux-loong64@0.28.1':
- resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
- engines: {node: '>=18'}
- cpu: [loong64]
- os: [linux]
-
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
- '@esbuild/linux-mips64el@0.28.1':
- resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
- engines: {node: '>=18'}
- cpu: [mips64el]
- os: [linux]
-
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
- '@esbuild/linux-ppc64@0.28.1':
- resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
- engines: {node: '>=18'}
- cpu: [ppc64]
- os: [linux]
-
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
- '@esbuild/linux-riscv64@0.28.1':
- resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
- engines: {node: '>=18'}
- cpu: [riscv64]
- os: [linux]
-
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
- '@esbuild/linux-s390x@0.28.1':
- resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
- engines: {node: '>=18'}
- cpu: [s390x]
- os: [linux]
-
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
- '@esbuild/linux-x64@0.28.1':
- resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [linux]
-
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
- '@esbuild/netbsd-arm64@0.28.1':
- resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [netbsd]
-
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
- '@esbuild/netbsd-x64@0.28.1':
- resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [netbsd]
-
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
- '@esbuild/openbsd-arm64@0.28.1':
- resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openbsd]
-
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
- '@esbuild/openbsd-x64@0.28.1':
- resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [openbsd]
-
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
- '@esbuild/openharmony-arm64@0.28.1':
- resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [openharmony]
-
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
- '@esbuild/sunos-x64@0.28.1':
- resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [sunos]
-
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
- '@esbuild/win32-arm64@0.28.1':
- resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
- engines: {node: '>=18'}
- cpu: [arm64]
- os: [win32]
-
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
- '@esbuild/win32-ia32@0.28.1':
- resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
- engines: {node: '>=18'}
- cpu: [ia32]
- os: [win32]
-
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
- '@esbuild/win32-x64@0.28.1':
- resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
- engines: {node: '>=18'}
- cpu: [x64]
- os: [win32]
-
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
@@ -1721,6 +1568,13 @@ packages:
cpu: [x64]
os: [win32]
+ '@sfourdrinier/react-native-ble-plx@3.9.3':
+ resolution: {integrity: sha512-RwliDCJl4KKakP4cy5WFO+OoPTVkLOGl+s1hSSrW4SYvJ6Ht9RNKHGXmX43O4BvOMpda69DA14rIBLV8Yrcmqg==}
+ engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >=25.0.0}
+ peerDependencies:
+ react: '*'
+ react-native: '>=0.86.0'
+
'@sinclair/typebox@0.27.12':
resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==}
@@ -2359,11 +2213,6 @@ packages:
es-module-lexer@2.3.2:
resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
- esbuild@0.28.1:
- resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
- engines: {node: '>=18'}
- hasBin: true
-
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
@@ -4351,159 +4200,81 @@ snapshots:
tslib: 2.8.1
optional: true
- '@esbuild/aix-ppc64@0.28.1':
- optional: true
-
'@esbuild/aix-ppc64@0.28.2':
optional: true
- '@esbuild/android-arm64@0.28.1':
- optional: true
-
'@esbuild/android-arm64@0.28.2':
optional: true
- '@esbuild/android-arm@0.28.1':
- optional: true
-
'@esbuild/android-arm@0.28.2':
optional: true
- '@esbuild/android-x64@0.28.1':
- optional: true
-
'@esbuild/android-x64@0.28.2':
optional: true
- '@esbuild/darwin-arm64@0.28.1':
- optional: true
-
'@esbuild/darwin-arm64@0.28.2':
optional: true
- '@esbuild/darwin-x64@0.28.1':
- optional: true
-
'@esbuild/darwin-x64@0.28.2':
optional: true
- '@esbuild/freebsd-arm64@0.28.1':
- optional: true
-
'@esbuild/freebsd-arm64@0.28.2':
optional: true
- '@esbuild/freebsd-x64@0.28.1':
- optional: true
-
'@esbuild/freebsd-x64@0.28.2':
optional: true
- '@esbuild/linux-arm64@0.28.1':
- optional: true
-
'@esbuild/linux-arm64@0.28.2':
optional: true
- '@esbuild/linux-arm@0.28.1':
- optional: true
-
'@esbuild/linux-arm@0.28.2':
optional: true
- '@esbuild/linux-ia32@0.28.1':
- optional: true
-
'@esbuild/linux-ia32@0.28.2':
optional: true
- '@esbuild/linux-loong64@0.28.1':
- optional: true
-
'@esbuild/linux-loong64@0.28.2':
optional: true
- '@esbuild/linux-mips64el@0.28.1':
- optional: true
-
'@esbuild/linux-mips64el@0.28.2':
optional: true
- '@esbuild/linux-ppc64@0.28.1':
- optional: true
-
'@esbuild/linux-ppc64@0.28.2':
optional: true
- '@esbuild/linux-riscv64@0.28.1':
- optional: true
-
'@esbuild/linux-riscv64@0.28.2':
optional: true
- '@esbuild/linux-s390x@0.28.1':
- optional: true
-
'@esbuild/linux-s390x@0.28.2':
optional: true
- '@esbuild/linux-x64@0.28.1':
- optional: true
-
'@esbuild/linux-x64@0.28.2':
optional: true
- '@esbuild/netbsd-arm64@0.28.1':
- optional: true
-
'@esbuild/netbsd-arm64@0.28.2':
optional: true
- '@esbuild/netbsd-x64@0.28.1':
- optional: true
-
'@esbuild/netbsd-x64@0.28.2':
optional: true
- '@esbuild/openbsd-arm64@0.28.1':
- optional: true
-
'@esbuild/openbsd-arm64@0.28.2':
optional: true
- '@esbuild/openbsd-x64@0.28.1':
- optional: true
-
'@esbuild/openbsd-x64@0.28.2':
optional: true
- '@esbuild/openharmony-arm64@0.28.1':
- optional: true
-
'@esbuild/openharmony-arm64@0.28.2':
optional: true
- '@esbuild/sunos-x64@0.28.1':
- optional: true
-
'@esbuild/sunos-x64@0.28.2':
optional: true
- '@esbuild/win32-arm64@0.28.1':
- optional: true
-
'@esbuild/win32-arm64@0.28.2':
optional: true
- '@esbuild/win32-ia32@0.28.1':
- optional: true
-
'@esbuild/win32-ia32@0.28.2':
optional: true
- '@esbuild/win32-x64@0.28.1':
- optional: true
-
'@esbuild/win32-x64@0.28.2':
optional: true
@@ -5346,6 +5117,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.63.1':
optional: true
+ '@sfourdrinier/react-native-ble-plx@3.9.3(react-native@0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ react-native: 0.86.3(@babel/core@7.29.7)(@types/react@19.2.18)(react@19.2.3)
+
'@sinclair/typebox@0.27.12': {}
'@sindresorhus/is@4.6.0': {}
@@ -5940,35 +5716,6 @@ snapshots:
es-module-lexer@2.3.2: {}
- esbuild@0.28.1:
- optionalDependencies:
- '@esbuild/aix-ppc64': 0.28.1
- '@esbuild/android-arm': 0.28.1
- '@esbuild/android-arm64': 0.28.1
- '@esbuild/android-x64': 0.28.1
- '@esbuild/darwin-arm64': 0.28.1
- '@esbuild/darwin-x64': 0.28.1
- '@esbuild/freebsd-arm64': 0.28.1
- '@esbuild/freebsd-x64': 0.28.1
- '@esbuild/linux-arm': 0.28.1
- '@esbuild/linux-arm64': 0.28.1
- '@esbuild/linux-ia32': 0.28.1
- '@esbuild/linux-loong64': 0.28.1
- '@esbuild/linux-mips64el': 0.28.1
- '@esbuild/linux-ppc64': 0.28.1
- '@esbuild/linux-riscv64': 0.28.1
- '@esbuild/linux-s390x': 0.28.1
- '@esbuild/linux-x64': 0.28.1
- '@esbuild/netbsd-arm64': 0.28.1
- '@esbuild/netbsd-x64': 0.28.1
- '@esbuild/openbsd-arm64': 0.28.1
- '@esbuild/openbsd-x64': 0.28.1
- '@esbuild/openharmony-arm64': 0.28.1
- '@esbuild/sunos-x64': 0.28.1
- '@esbuild/win32-arm64': 0.28.1
- '@esbuild/win32-ia32': 0.28.1
- '@esbuild/win32-x64': 0.28.1
-
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
@@ -7362,7 +7109,7 @@ snapshots:
vite@7.3.6(@types/node@22.20.1)(lightningcss@1.33.0)(terser@5.51.2)(yaml@2.9.0):
dependencies:
- esbuild: 0.28.1
+ esbuild: 0.28.2
fdir: 6.5.0(picomatch@4.0.7)
picomatch: 4.0.7
postcss: 8.5.26