Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/mobile/src/fixtures/codecs/neewer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
72 changes: 72 additions & 0 deletions apps/mobile/src/fixtures/codecs/neewer.ts
Original file line number Diff line number Diff line change
@@ -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 <tag> <len> <MAC> <payload…> <checksum>`: `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<number>) => { 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<Curve, number> = { 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;
}
78 changes: 78 additions & 0 deletions apps/mobile/src/fixtures/drivers/neewerInfinity.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
77 changes: 77 additions & 0 deletions apps/mobile/src/fixtures/drivers/neewerInfinity.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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<void>((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<Session> {
const f = fixtures.find((x) => x.address === link.address) ?? fixtures[0];
const white = new Map<string, White>();
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(); },
};
},
};
}
4 changes: 3 additions & 1 deletion apps/mobile/src/fixtures/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/fixtures/hubCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
11 changes: 8 additions & 3 deletions apps/mobile/src/fixtures/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
20 changes: 17 additions & 3 deletions apps/mobile/src/fixtures/registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, Driver> = {};
const DRIVERS: Record<string, Driver> = {
"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);
Expand All @@ -22,6 +30,9 @@ const NEEWER_SERVICE = "69400001-b5a3-f393-e0a9-e50e24dcca99";

const NEEWER_LIGHTS = ["neewer-fs230b", "neewer-fs300b"];

/** NEEWER lights advertise "NW-<model code>&…"; the codes seen so far. */
const NEEWER_CODES: Record<string, string> = { "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.
Expand All @@ -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"];
Expand Down
Loading
Loading