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 ( + +