diff --git a/apps/mobile/src/fixtures/codecs/neewer.test.ts b/apps/mobile/src/fixtures/codecs/neewer.test.ts new file mode 100644 index 0000000..1c409c3 --- /dev/null +++ b/apps/mobile/src/fixtures/codecs/neewer.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { toHex } from "../bytes.ts"; +import { frame, helloFrames, macBytes, modelOf, parseNotice, powerFrame, whiteFrame } from "./neewer.ts"; + +// A made-up address; the layout and checksums were checked against a capture of the NEEWER app (#207). +const MAC = "A1:B2:C3:D4:E5:F6"; + +test("power", () => { + assert.equal(toHex(powerFrame(MAC, true)), "78 8d 08 a1 b2 c3 d4 e5 f6 81 01 54"); + assert.equal(toHex(powerFrame(MAC, false)), "78 8d 08 a1 b2 c3 d4 e5 f6 81 02 55"); +}); + +test("intensity, white and curve in one frame", () => { + // 50 %, 6500 K, neutral, linear: as the app sends after a drag to 50. + assert.equal(toHex(whiteFrame(MAC, { intensity: 0.5, kelvin: 6500, curve: "linear" })), "78 90 0c a1 b2 c3 d4 e5 f6 87 32 41 32 04 00 09"); + const f = whiteFrame(MAC, { intensity: 0.1, kelvin: 2700, curve: "exp" }); + assert.deepEqual(Array.from(f.subarray(9, 15)), [0x87, 10, 27, 50, 1, 0]); + assert.equal(whiteFrame(MAC, { intensity: 1, kelvin: 4500, curve: "s" })[13], 3); + assert.equal(whiteFrame(MAC, { intensity: 1, kelvin: 4500, curve: "log" })[13], 2); +}); + +test("values are clamped and rounded", () => { + const f = whiteFrame(MAC, { intensity: 1.7, kelvin: 4449, curve: "linear", gm: 99 }); + assert.deepEqual([f[10], f[11], f[12]], [100, 44, 100]); + assert.equal(whiteFrame(MAC, { intensity: -1, kelvin: 2700, curve: "linear" })[10], 0); +}); + +test("the frame layout: length counts MAC + payload, checksum is the byte sum", () => { + const f = frame(0x95, MAC, []); + assert.equal(f[2], 6); + assert.equal(f.length, 10); + assert.equal(f[9], Array.from(f.subarray(0, 9)).reduce((a, b) => a + b, 0) & 0xff); + assert.equal(helloFrames(MAC).length, 6); + assert.throws(() => macBytes("A1:B2")); +}); + +test("notifications: the info reply carries the model name", () => { + const body = [0x78, 0x08, 0x12, ...macBytes(MAC), 0x01, 0x0a, 0x01, 0x29, 0x0f, 0x02, ...Buffer.from("FS150B")]; + const sum = body.reduce((a, b) => a + b, 0) & 0xff; + const n = parseNotice(Uint8Array.from([...body, sum]))!; + assert.equal(n.ok, true); + assert.equal(n.address, MAC); + assert.equal(modelOf(n), "FS150B"); + assert.equal(parseNotice(Uint8Array.of(0x42, 0x66)), null); + assert.equal(modelOf(parseNotice(powerFrame(MAC, true))!), null); +}); diff --git a/apps/mobile/src/fixtures/codecs/neewer.ts b/apps/mobile/src/fixtures/codecs/neewer.ts new file mode 100644 index 0000000..7d10262 --- /dev/null +++ b/apps/mobile/src/fixtures/codecs/neewer.ts @@ -0,0 +1,72 @@ +import type { Curve } from "@openclapper/vocab"; + +/** + * NEEWER "Infinity" lights (FS230B, FS300B), decoded from an HCI snoop of the NEEWER app (#207, + * #209). Every frame is `78 `: `len` counts the MAC and the + * payload, the checksum is the sum of all bytes before it. The MAC is the light's own Bluetooth + * address, in the order Android shows it. + */ +export const NEEWER_SERVICE = "69400001-b5a3-f393-e0a9-e50e24dcca99"; +export const NEEWER_WRITE = "69400002-b5a3-f393-e0a9-e50e24dcca99"; +export const NEEWER_NOTIFY = "69400003-b5a3-f393-e0a9-e50e24dcca99"; + +const PREFIX = 0x78; + +export function macBytes(address: string): number[] { + const parts = address.split(":").map((x) => parseInt(x, 16)); + if (parts.length !== 6 || parts.some((x) => !(x >= 0 && x <= 255))) throw new Error(`not a Bluetooth address: ${address}`); + return parts; +} + +export const checksum = (bytes: ArrayLike) => { let sum = 0; for (let i = 0; i < bytes.length; i++) sum += bytes[i]; return sum & 0xff; }; + +export function frame(tag: number, address: string, payload: number[]): Uint8Array { + const body = [PREFIX, tag, 6 + payload.length, ...macBytes(address), ...payload]; + return Uint8Array.from([...body, checksum(body)]); +} + +/** The curve byte of the white-light command, as the app's four buttons send it. */ +export const CURVE_BYTE: Record = { exp: 1, log: 2, s: 3, linear: 4 }; + +export const powerFrame = (address: string, on: boolean) => frame(0x8d, address, [0x81, on ? 0x01 : 0x02]); + +/** + * Intensity, white and curve travel together: the app has no intensity-only command, so the + * driver always sends all three. Kelvin in hundreds (2700 K = 27); green/magenta 50 = neutral, the + * only value the app sends for the bi-colour lights. + */ +export function whiteFrame(address: string, w: { intensity: number; kelvin: number; curve: Curve; gm?: number }): Uint8Array { + const bri = Math.round(Math.min(1, Math.max(0, w.intensity)) * 100); + const cct = Math.round(w.kelvin / 100); + const gm = Math.round(Math.min(50, Math.max(-50, w.gm ?? 0))) + 50; + return frame(0x90, address, [0x87, bri, cct, gm, CURVE_BYTE[w.curve], 0x00]); +} + +/** What the app sends right after connecting: a hello and five queries. Replayed so the light sees the same start. */ +export const helloFrames = (address: string): Uint8Array[] => [ + frame(0x9f, address, [0x01, 0x00, 0xff, 0xff, 0xff, 0xff]), + frame(0x8e, address, [0x85]), + frame(0x95, address, []), + frame(0xd3, address, [0x00]), + frame(0xd1, address, []), + frame(0x9e, address, []), +]; + +export interface Notice { tag: number; address: string; payload: number[]; ok: boolean } + +/** A notification frame, or null when it is not one (a continuation of a longer reply). */ +export function parseNotice(b: Uint8Array): Notice | null { + if (b.length < 10 || b[0] !== PREFIX) return null; + const len = b[2]; + const end = 3 + len; + const address = Array.from(b.subarray(3, 9), (x) => x.toString(16).padStart(2, "0").toUpperCase()).join(":"); + return { tag: b[1], address, payload: Array.from(b.subarray(9, Math.min(end, b.length))), ok: b.length > end && b[end] === checksum(b.subarray(0, end)) }; +} + +/** The model name in the info reply (tag 0x08), e.g. "FS150B" from an FS230B: they share firmware. */ +export function modelOf(n: Notice): string | null { + if (n.tag !== 0x08) return null; + const text = String.fromCharCode(...n.payload.filter((x) => x >= 0x20 && x < 0x7f)); + const m = /[A-Z]{2}\d{2,4}[A-Z]?/.exec(text); + return m ? m[0] : null; +} diff --git a/apps/mobile/src/fixtures/drivers/neewerInfinity.test.ts b/apps/mobile/src/fixtures/drivers/neewerInfinity.test.ts new file mode 100644 index 0000000..e254e8a --- /dev/null +++ b/apps/mobile/src/fixtures/drivers/neewerInfinity.test.ts @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { toHex } from "../bytes.ts"; +import { powerFrame, whiteFrame } from "../codecs/neewer.ts"; +import type { Fixture, Link } from "../types.ts"; +import { AFTER_POWER_ON_MS, neewerInfinity } from "./neewerInfinity.ts"; + +const MAC = "A1:B2:C3:D4:E5:F6"; +const light: Fixture = { id: "f1", label: "Key", kindId: "neewer-fs230b", address: MAC, mesh: null, createdAt: "", synced: true }; + +function setup() { + const writes: { bytes: string; withResponse: boolean; at: number }[] = []; + const clock = { t: 0, waits: [] as number[] }; + let notifyOn = false; + const link: Link = { + address: MAC, mtu: 247, + write: async (_s, _c, bytes, withResponse) => { writes.push({ bytes: toHex(bytes), withResponse, at: clock.t }); }, + notify: async () => { notifyOn = true; return () => { notifyOn = false; }; }, + onDisconnect: () => () => {}, + close: async () => {}, + }; + const driver = neewerInfinity({ now: () => clock.t, wait: async (ms) => { clock.waits.push(ms); clock.t += ms; } }); + return { writes, clock, link, driver, notifying: () => notifyOn }; +} + +test("opening turns notifications on and replays the app's hello, without response", async () => { + const { writes, link, driver, notifying } = setup(); + const s = await driver.open(link, [light], () => {}); + assert.equal(notifying(), true); + assert.equal(writes.length, 6); + assert.ok(writes.every((w) => !w.withResponse)); + await s.close(); + assert.equal(notifying(), false); +}); + +test("intensity, white and curve always go out together with the last known others", async () => { + const { writes, link, driver } = setup(); + const s = await driver.open(link, [light], () => {}); + writes.length = 0; + await s.send(light, { op: "intensity", value: 0.3 }); + // Until set, white is the middle of the kind's range (2700–6500 → 4600 K) and the curve linear. + assert.equal(writes[0].bytes, toHex(whiteFrame(MAC, { intensity: 0.3, kelvin: 4600, curve: "linear" }))); + await s.send(light, { op: "cct", kelvin: 3200, gm: 0 }); + await s.send(light, { op: "curve", curve: "s" }); + assert.equal(writes[2].bytes, toHex(whiteFrame(MAC, { intensity: 0.3, kelvin: 3200, curve: "s" }))); +}); + +test("a white frame right after power-on waits for the light", async () => { + const { writes, clock, link, driver } = setup(); + const s = await driver.open(link, [light], () => {}); + writes.length = 0; + await s.send(light, { op: "power", on: true }); + clock.t += 40; + await s.send(light, { op: "intensity", value: 1 }); + assert.equal(writes[0].bytes, toHex(powerFrame(MAC, true))); + assert.deepEqual(clock.waits, [AFTER_POWER_ON_MS - 40]); + // Long after: no wait. + clock.t += 1000; + await s.send(light, { op: "intensity", value: 0.5 }); + assert.equal(clock.waits.length, 1); +}); + +test("green/magenta goes out with the white (FS300B)", async () => { + const { writes, link, driver } = setup(); + const fs300: Fixture = { ...light, id: "f2", kindId: "neewer-fs300b" }; + const s = await driver.open(link, [fs300], () => {}); + writes.length = 0; + await s.send(fs300, { op: "cct", kelvin: 5600, gm: -20 }); + await s.send(fs300, { op: "intensity", value: 0.8 }); + assert.equal(writes[1].bytes, toHex(whiteFrame(MAC, { intensity: 0.8, kelvin: 5600, curve: "linear", gm: -20 }))); + assert.equal(whiteFrame(MAC, { intensity: 0.8, kelvin: 5600, curve: "linear", gm: -20 })[12], 30); +}); + +test("colour is refused: the bi-colour lights have none", async () => { + const { link, driver } = setup(); + const s = await driver.open(link, [light], () => {}); + await assert.rejects(s.send(light, { op: "hsi", h: 0, s: 1, i: 1 }), /has no hsi/); +}); diff --git a/apps/mobile/src/fixtures/drivers/neewerInfinity.ts b/apps/mobile/src/fixtures/drivers/neewerInfinity.ts new file mode 100644 index 0000000..ca12805 --- /dev/null +++ b/apps/mobile/src/fixtures/drivers/neewerInfinity.ts @@ -0,0 +1,77 @@ +import { fixtureKind, startState, type Curve } from "@openclapper/vocab"; +import { toHex } from "../bytes.ts"; +import { helloFrames, modelOf, NEEWER_NOTIFY, NEEWER_SERVICE, NEEWER_WRITE, parseNotice, powerFrame, whiteFrame } from "../codecs/neewer.ts"; +import type { Command, Driver, Fixture, Link, Session, Telemetry } from "../types.ts"; + +/** A light needs a moment after power-on before it takes a white-light frame. */ +export const AFTER_POWER_ON_MS = 150; +/** The pace the NEEWER app keeps while a slider moves. */ +export const MIN_INTERVAL_MS = 100; + +interface White { intensity: number; kelvin: number; curve: Curve; gm: number } + +export interface NeewerOptions { + wait?: (ms: number) => Promise; + now?: () => number; + log?: (level: "debug" | "info" | "warn", event: string, data?: unknown) => void; +} + +/** Until the screen sets them: the shared start values (vocab startState). */ +function startWhite(f: Fixture): White { + const kind = fixtureKind(f.kindId); + const s = kind ? startState(kind) : {}; + return { intensity: s.intensity ?? 0.5, kelvin: s.cct?.kelvin ?? 5600, curve: s.curve ?? "linear", gm: 0 }; +} + +/** + * NEEWER "Infinity" bi-colour lights over their own connection (#209). Intensity, white and the + * curve share one frame, so the session keeps each light's last values and always sends all three. + */ +export function neewerInfinity(opts: NeewerOptions = {}): Driver { + const wait = opts.wait ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = opts.now ?? (() => Date.now()); + const log = opts.log ?? (() => {}); + return { + id: "neewer-infinity", + linkKey: (f) => f.address, + rate: { minIntervalMs: MIN_INTERVAL_MS }, + addressesFor: (key) => [key], + async open(link: Link, fixtures: Fixture[], sink: (id: string, t: Telemetry) => void): Promise { + const f = fixtures.find((x) => x.address === link.address) ?? fixtures[0]; + const white = new Map(); + let poweredOnAt = Number.NEGATIVE_INFINITY; + const stop = await link.notify(NEEWER_SERVICE, NEEWER_NOTIFY, (b) => { + log("debug", "neewer notify", { fixture: f?.id, bytes: toHex(b) }); + const n = parseNotice(b); + const model = n && modelOf(n); + if (model && f) { log("info", "neewer model", { fixture: f.id, model }); sink(f.id, { at: now() }); } + }); + const write = async (bytes: Uint8Array) => { + log("debug", "neewer write", { bytes: toHex(bytes) }); + await link.write(NEEWER_SERVICE, NEEWER_WRITE, bytes, false); + }; + if (f) for (const h of helloFrames(f.address)) await write(h); + return { + async send(fx: Fixture, cmd: Command) { + const w = white.get(fx.id) ?? startWhite(fx); + switch (cmd.op) { + case "power": + await write(powerFrame(fx.address, cmd.on)); + if (cmd.on) poweredOnAt = now(); + return; + case "intensity": w.intensity = cmd.value; break; + case "cct": w.kelvin = cmd.kelvin; w.gm = cmd.gm; break; + case "curve": w.curve = cmd.curve; break; + case "status": return; // the light reports nothing useful yet + default: throw new Error(`${fx.kindId} has no ${cmd.op}`); + } + white.set(fx.id, w); + const left = poweredOnAt + AFTER_POWER_ON_MS - now(); + if (left > 0) await wait(left); + await write(whiteFrame(fx.address, w)); + }, + async close() { stop(); }, + }; + }, + }; +} diff --git a/apps/mobile/src/fixtures/hub.ts b/apps/mobile/src/fixtures/hub.ts index d971e88..e5a25b2 100644 --- a/apps/mobile/src/fixtures/hub.ts +++ b/apps/mobile/src/fixtures/hub.ts @@ -4,9 +4,11 @@ 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 { driverFor, setDriverLog } from "./registry"; import type { Command } from "./types"; +setDriverLog(log); + /** The app's one hub (fixtures/hubCore.ts) on the real Bluetooth stack and clock. */ export const hub = new Hub({ fixtures: getFixtures, diff --git a/apps/mobile/src/fixtures/hubCore.ts b/apps/mobile/src/fixtures/hubCore.ts index ea0b210..03f5391 100644 --- a/apps/mobile/src/fixtures/hubCore.ts +++ b/apps/mobile/src/fixtures/hubCore.ts @@ -61,6 +61,7 @@ export function applyCommand(s: LightState, cmd: Command): LightState { 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 }; + case "curve": return { ...s, curve: cmd.curve }; default: return s; } } diff --git a/apps/mobile/src/fixtures/registry.test.ts b/apps/mobile/src/fixtures/registry.test.ts index 172e673..b822ffc 100644 --- a/apps/mobile/src/fixtures/registry.test.ts +++ b/apps/mobile/src/fixtures/registry.test.ts @@ -4,13 +4,18 @@ 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"]); + // The FS230B advertises its model code; an unknown code falls back to every NEEWER kind. + assert.deepEqual(suggestKinds({ name: "NW-20230051&FFFFFFFF", serviceUUIDs: null }), ["neewer-fs230b", "neewer-fs300b", "neewer-ps099s"]); + assert.deepEqual(suggestKinds({ name: "NW-20239999&FFFFFFFF", 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); +test("drivers: NEEWER lights have one; the battery and Aputure not yet (#210, #213)", () => { + assert.equal(driverFor("neewer-fs230b")?.id, "neewer-infinity"); + assert.equal(driverFor("neewer-fs300b")?.id, "neewer-infinity"); + assert.equal(driverFor("neewer-ps099s"), undefined); + assert.equal(driverFor("aputure-mc"), undefined); assert.equal(driverFor("nope"), undefined); }); diff --git a/apps/mobile/src/fixtures/registry.ts b/apps/mobile/src/fixtures/registry.ts index 98237b7..ba7ef21 100644 --- a/apps/mobile/src/fixtures/registry.ts +++ b/apps/mobile/src/fixtures/registry.ts @@ -1,12 +1,20 @@ import { FIXTURE_KINDS, fixtureKind } from "@openclapper/vocab"; +import { neewerInfinity } from "./drivers/neewerInfinity.ts"; 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 + * The phone's drivers by id (FixtureKind.driver): NEEWER lights (#209); still to come the PS099S + * battery (#210) and 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 = {}; +const DRIVERS: Record = { + "neewer-infinity": neewerInfinity({ log: (level, event, data) => logSink(level, event, data) }), +}; + +type LogFn = (level: "debug" | "info" | "warn", event: string, data?: unknown) => void; +let logSink: LogFn = () => {}; +/** The debug log, set by hub.ts (this file stays free of React Native for the tests). */ +export function setDriverLog(fn: LogFn) { logSink = fn; } export const driverFor = (kindId: string): Driver | undefined => { const kind = fixtureKind(kindId); @@ -22,6 +30,9 @@ const NEEWER_SERVICE = "69400001-b5a3-f393-e0a9-e50e24dcca99"; const NEEWER_LIGHTS = ["neewer-fs230b", "neewer-fs300b"]; +/** NEEWER lights advertise "NW-&…"; the codes seen so far. */ +const NEEWER_CODES: Record = { "20230051": "neewer-fs230b" }; + /** * 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. @@ -31,6 +42,9 @@ export function suggestKinds(a: Advert): string[] { 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)) { + const code = /^nw[-_ ]?(\d{8})/i.exec(name)?.[1]; + const known = code ? NEEWER_CODES[code] : undefined; + if (known) return [known, ...NEEWER_LIGHTS.filter((k) => k !== known), "neewer-ps099s"]; 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"]; diff --git a/apps/mobile/src/screens/Lights.tsx b/apps/mobile/src/screens/Lights.tsx index af3beb7..e0038cd 100644 --- a/apps/mobile/src/screens/Lights.tsx +++ b/apps/mobile/src/screens/Lights.tsx @@ -1,16 +1,16 @@ import { useEffect, useRef, useState } from "react"; -import { Text } from "react-native"; +import { Text, View } from "react-native"; import * as Crypto from "expo-crypto"; -import { fixtureKind, fixtureKindName, FIXTURE_LABEL_MAX, type FixtureKind } from "@openclapper/vocab"; +import { fixtureKind, fixtureKindName, FIXTURE_LABEL_MAX, startState, type FixtureKind, type LightState } 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 { linkLabel, sendToFixture, 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 type { Command, 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 { Banner, Block, Button, confirm, Empty, ErrorText, FieldRow, Hint, Input, ListRow, makeStyles, num, PushScreen, SectionLabel, Seg, SideBySide, Slider, toast, type } from "../ui"; import { OptionSheet } from "../components/OptionSheet"; const kindIcon = (k: FixtureKind | undefined) => (k && !k.caps.power ? "battery-high" : "lightbulb-outline"); @@ -183,23 +183,74 @@ export function FixtureEditor({ fixtureId }: { fixtureId: string | null }) { ); } -/** One fixture: its connection, and (from #209 on) its controls. */ +const CURVE_OPTIONS = [ + { id: "linear", label: "Linear" }, + { id: "exp", label: "Expo" }, + { id: "log", label: "Log" }, + { id: "s", label: "S" }, +] as const; + +/** Send and forget: the hub coalesces, and a refused write is in the debug log. */ +const fire = (id: string, cmd: Command) => { void sendToFixture(id, cmd).catch((e) => log("debug", "fixture send dropped", { fixture: id, error: e })); }; + +/** + * One fixture: its connection, held open while shown, and the controls its kind has. The lights + * report nothing back yet, so the controls start from what this phone last set (or the shared + * start values) and send only what is touched. + */ 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); + const kind = f ? fixtureKind(f.kindId) : undefined; + const [v, setV] = useState(() => ({ ...(kind ? startState(kind) : {}), ...status.state })); if (!f) return ; - const kind = fixtureKind(f.kindId); + const ready = status.link === "ready"; + const caps = kind?.caps; + const send = (patch: LightState, cmd: Command) => { setV((cur) => ({ ...cur, ...patch })); fire(f.id, cmd); }; + const cct = v.cct ?? { kelvin: 5600, gm: 0 }; + const controllable = !!caps && (caps.power || caps.intensity || !!caps.cct); 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.} + {caps && controllable && status.link !== "failed" && ( + + {caps.power && ( + + Power + send({ power: x === "on" }, { op: "power", on: x === "on" })} options={[{ id: "off", label: "Off" }, { id: "on", label: "On" }]} /> + + )} + {caps.intensity && ( + `${x} %`} disabled={!ready} + onChange={(x) => send({ intensity: x / 100 }, { op: "intensity", value: x / 100 })} /> + )} + {caps.cct && ( + `${x} K`} disabled={!ready} + onChange={(x) => send({ cct: { ...cct, kelvin: x } }, { op: "cct", kelvin: x, gm: cct.gm })} /> + )} + {caps.cct?.gm && ( + (x > 0 ? `+${x} M` : x < 0 ? `${-x} G` : "neutral")} disabled={!ready} + onChange={(x) => send({ cct: { ...cct, gm: x } }, { op: "cct", kelvin: cct.kelvin, gm: x })} /> + )} + {caps.curves && ( + + Dimming curve + send({ curve: x }, { op: "curve", curve: x })} options={CURVE_OPTIONS} /> + + )} + The light does not report its settings yet: the controls show what this phone last set. + + )} + {kind && {capsLine(kind)}} ); } diff --git a/apps/mobile/src/ui/forms.tsx b/apps/mobile/src/ui/forms.tsx index 349f79f..b2a012d 100644 --- a/apps/mobile/src/ui/forms.tsx +++ b/apps/mobile/src/ui/forms.tsx @@ -1,9 +1,9 @@ /** Form controls from the design system: chips, input, switch, toggle row, checkbox. */ -import { useState, type ReactNode } from "react"; -import { Pressable, Text, TextInput, View, type TextInputProps } from "react-native"; +import { useMemo, useRef, useState, type ReactNode } from "react"; +import { PanResponder, Pressable, Text, TextInput, View, type TextInputProps } from "react-native"; import { IconButton } from "./actions"; import { Icon } from "./core"; -import { SIZE, useTheme } from "./theme"; +import { makeStyles, num, SIZE, type, useTheme } from "./theme"; import { useStyles } from "./styles"; export function Row({ label, children }: { label: string; children?: ReactNode }) { @@ -110,3 +110,72 @@ export function ChipCell({ children }: { children: ReactNode }) { const s = useStyles(); return {children}; } + +/** + * A value on a track: drag anywhere on it, or step with − and + for an exact value (on set, in + * gloves). Hand-written on PanResponder like components/gestures.ts. `onChange` fires on every + * step while dragging; callers that send it somewhere coalesce (fixtures/queue.ts). + */ +export function Slider({ label, value, min, max, step = 1, format = String, onChange, disabled }: { label: string; value: number; min: number; max: number; step?: number; format?: (v: number) => string; onChange: (v: number) => void; disabled?: boolean }) { + const s = useSliderStyles(); + const [width, setWidth] = useState(0); + const r = useRef({ min, max, step, width, onChange, disabled, x0: 0, last: value }); + r.current = { ...r.current, min, max, step, width, onChange, disabled, last: value }; + const snap = (v: number) => { + const { min: lo, max: hi, step: st } = r.current; + return Math.min(hi, Math.max(lo, Math.round((v - lo) / st) * st + lo)); + }; + const emit = (v: number) => { + const next = snap(v); + if (next === r.current.last) return; + r.current.last = next; + r.current.onChange(next); + }; + const at = (x: number) => { const { min: lo, max: hi, width: w } = r.current; return w > 0 ? lo + (x / w) * (hi - lo) : lo; }; + const pan = useMemo(() => PanResponder.create({ + onStartShouldSetPanResponder: () => !r.current.disabled, + onMoveShouldSetPanResponder: () => !r.current.disabled, + // A slider inside a scroll view keeps the drag once it has it. + onPanResponderTerminationRequest: () => false, + onPanResponderGrant: (e) => { r.current.x0 = e.nativeEvent.locationX; emit(at(r.current.x0)); }, + onPanResponderMove: (_, g) => emit(at(r.current.x0 + g.dx)), + }).panHandlers, []); + const frac = max > min ? (Math.min(max, Math.max(min, value)) - min) / (max - min) : 0; + const nudge = (dir: 1 | -1) => emit(value + dir * step); + return ( + + + {label} + {format(value)} + + + nudge(-1)} disabled={disabled || value <= min} /> + setWidth(e.nativeEvent.layout.width)} {...pan} + accessible accessibilityRole="adjustable" accessibilityLabel={label} accessibilityValue={{ min, max, now: value, text: format(value) }} + accessibilityActions={[{ name: "increment" }, { name: "decrement" }]} onAccessibilityAction={(e) => nudge(e.nativeEvent.actionName === "increment" ? 1 : -1)}> + + + + + nudge(1)} disabled={disabled || value >= max} /> + + + ); +} + +const THUMB = 26; +const useSliderStyles = makeStyles((c) => ({ + wrap: { gap: 4 }, + head: { flexDirection: "row", justifyContent: "space-between" }, + label: { ...type("body", "semibold"), color: c.text }, + value: { ...type("body", "semibold"), color: c.text, ...num }, + dim: { color: c.textDisabled }, + row: { flexDirection: "row", alignItems: "center", gap: 8 }, + track: { flex: 1, height: SIZE.touchMin, justifyContent: "center" }, + rail: { position: "absolute", left: 0, right: 0, height: 6, borderRadius: 3, backgroundColor: c.surfaceSunken, borderWidth: 1, borderColor: c.border }, + railOff: { borderColor: c.borderSubtle }, + fill: { position: "absolute", left: 0, height: 6, borderRadius: 3, backgroundColor: c.accent }, + fillOff: { backgroundColor: c.textDisabled }, + thumb: { position: "absolute", width: THUMB, height: THUMB, borderRadius: THUMB / 2, backgroundColor: c.surface, borderWidth: 3, borderColor: c.accent }, + thumbOff: { borderColor: c.textDisabled }, +})); diff --git a/docs/design.md b/docs/design.md index 0139ee9..09fcb64 100644 --- a/docs/design.md +++ b/docs/design.md @@ -130,7 +130,7 @@ Domain pieces that draw photos (`Framed`, `FramedThumb`, `ShotCard`) stay next t | `theme.tsx` | Palettes, `FIXED`, `FONT`, `type(step, weight)`, `num`, `SPACE`, `RADIUS`, `SIZE` (incl. `header` 64, `actionColumn` 148), `BORDER` (`control` 2, `badge` 1.5), `ThemeProvider`, `useTheme`, `makeStyles` | | `core.tsx` | `useLayoutSize`, `Icon`, `Mark`, `SectionLabel`, `SectionHeading` (padded, between full-width rows), `Hint` | | `actions.tsx` | `Button`, `IconButton`, `SideBySide` (two controls 8 dp apart) | -| `forms.tsx` | `Chip`, `ChipRow`, `ChipGrid`/`ChipCell`, `Input`, `PasswordInput` (an eye button beside it shows the password), `Switch`, `Toggle`, `Checkbox`, `Row` | +| `forms.tsx` | `Chip`, `ChipRow`, `ChipGrid`/`ChipCell`, `Input`, `PasswordInput` (an eye button beside it shows the password), `Switch`, `Toggle`, `Checkbox`, `Row`, `Slider` (drag on a 48 dp track, or − and + for exact steps; lights) | | `navigation.tsx` | `Seg` | | `data.tsx` | `StateMarker`, `SeqBadge`, `PhotoTag`, `ListRow` | | `feedback.tsx` | `Banner`, `Empty`, `Skeleton`, `ErrorText`, `useRefreshControl` | diff --git a/docs/features/lights.md b/docs/features/lights.md index 19a2e93..d275c76 100644 --- a/docs/features/lights.md +++ b/docs/features/lights.md @@ -6,11 +6,11 @@ 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 FS230B, FS300B | Bluetooth LE, its own connection (GATT) | Power, intensity, white 2700–6500 K, dimming curves; green/magenta on the FS300B | | 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). +**Status:** pairing, the synced list and the connection manager (#208), and control of the NEEWER FS230B and FS300B (#209) are built. The battery ([#210](https://github.com/OpenClapper-dev/core/issues/210)) and Aputure over the mesh ([#211](https://github.com/OpenClapper-dev/core/issues/211)–[#213](https://github.com/OpenClapper-dev/core/issues/213)) are not: their screens say "the controls for this model are not built yet". ## Kinds (`packages/vocab/src/lighting.ts`) @@ -19,7 +19,7 @@ First fixtures: ## 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. +- **`registry.ts`**: drivers by id, and `suggestKinds`, which guesses the model from a scanned name or service: NEEWER lights advertise `NW-&…` (`20230051` is the FS230B; other codes fall back to every NEEWER kind), Aputure the mesh services. 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. @@ -31,12 +31,23 @@ First fixtures: - **`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. +## NEEWER lights (`drivers/neewerInfinity.ts`, `codecs/neewer.ts`, #209) + +Decoded from an HCI snoop of the NEEWER app driving an FS230B (#207); every captured frame was rebuilt byte for byte. Tested on an FS230B and an FS300B on 2026-10-23: every control, a fast drag and reconnecting. + +- **Connection**: service `69400001-b5a3-f393-e0a9-e50e24dcca99`; commands are written without response to `…0002`, replies come as notifications on `…0003`. On connect the driver turns notifications on and replays the app's hello and its five queries. +- **Frame**: `78 `; `len` counts the MAC and the payload, the checksum is the sum of every byte before it, the MAC is the light's own address. +- **Power**: tag `8d`, `81 01` on, `81 02` off. A light gets 150 ms after power-on before the next white frame. +- **Intensity, white and curve share one frame** (tag `90`, `87 00`). There is no intensity-only command, so the driver keeps each light's last values and always sends all three. Curve bytes: `1` exponential, `2` logarithmic, `3` S, `4` linear. The FS230B's app has no green/magenta control and always sends neutral (`50`); the FS300B gets a green/magenta slider on the same byte, −50..+50 (sent as +50), which follows the published NEEWER protocol (not captured, confirmed on the light). +- **Pace**: one write per 100 ms on a connection, as the app does while a slider moves; the queue coalesces faster input. +- The lights report their model in an info reply (an FS230B says "FS150B": same firmware), which goes to the debug log. They do not report their settings, so nothing is read back. + ## 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. +- **A light**: its connection state, held open while the screen is shown, and the controls its model has: power, intensity, white, green/magenta, dimming curve. Sliders drag or step with − and + (1 % and 100 K). Until the phone has set a value they show the shared start values (half intensity, the middle of the white range, linear), because the lights report nothing back. Controls are greyed out while not connected. ## On the server @@ -44,7 +55,8 @@ First fixtures: ## Not done -- Every driver (see Status above). +- The battery and Aputure drivers (see Status above). +- Reading a light's current settings: the NEEWER lights answer queries, but what the replies mean is not decoded. - 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/known-issues.md b/docs/known-issues.md index 9069ff5..5213499 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -4,7 +4,6 @@ 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 65b0eba..7bf2832 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; lights: the command queue, the connection manager (fake link and timers), scan suggestions, byte helpers (`src/**/*.test.ts`) | +| `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, the NEEWER frames and driver (`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/lighting.ts b/packages/vocab/src/lighting.ts index 7868a8f..20d4f37 100644 --- a/packages/vocab/src/lighting.ts +++ b/packages/vocab/src/lighting.ts @@ -46,9 +46,10 @@ const LIGHT = { power: true, intensity: true, hsi: false, curves: true, telemetr * 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). + // Bi-colour COB lights of NEEWER's "Infinity" app (#209). { 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 } } }, + // Green/magenta in the frame's own steps (−50..+50, sent as +50); the FS230B's app has no control for it. + { id: "neewer-fs300b", brand: "NEEWER", model: "FS300B", transport: "gatt", driver: "neewer-infinity", caps: { ...LIGHT, cct: { min: 2700, max: 6500, gm: { min: -50, max: 50 } } } }, // V-mount battery in the same app: telemetry only. { id: "neewer-ps099s", brand: "NEEWER", model: "PS099S", transport: "gatt", driver: "neewer-battery", @@ -67,6 +68,21 @@ export interface LightState { intensity?: number; cct?: { kelvin: number; gm: number }; hsi?: { h: number; s: number; i: number }; + /** The fixture's own dimming curve (NEEWER: part of every white-light command). */ + curve?: Curve; +} + +/** + * What the phone assumes until it has set a value itself: the lights report nothing back yet. + * Half intensity, the middle of the white range (to 100 K), linear curve. + */ +export function startState(k: FixtureKind): LightState { + const cct = k.caps.cct; + return { + intensity: 0.5, + ...(cct ? { cct: { kelvin: Math.round((cct.min + cct.max) / 200) * 100, gm: 0 } } : {}), + ...(k.caps.curves ? { curve: "linear" as const } : {}), + }; } export const FIXTURE_LABEL_MAX = 60;