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
4 changes: 4 additions & 0 deletions apps/mobile/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -258,6 +259,9 @@ function Screen({ route }: { route: Route }) {
case "step": return <StepThrough day={route.day} shots={route.shots} index={route.index} />;
case "rigs": return <RigList />;
case "rigEditor": return <RigEditor key={route.rigId ?? "new"} rigId={route.rigId} />;
case "fixtures": return <FixtureList />;
case "fixtureEditor": return <FixtureEditor key={route.fixtureId ?? "new"} fixtureId={route.fixtureId} />;
case "fixture": return <FixtureControl key={route.fixtureId} fixtureId={route.fixtureId} />;
case "viewfinderSettings": return <ViewfinderSettings />;
case "captureSettings": return <CaptureSettings />;
case "calibration": return <Calibration />;
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
]
]
}
}
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
26 changes: 26 additions & 0 deletions apps/mobile/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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" }),

/**
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/appState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
65 changes: 65 additions & 0 deletions apps/mobile/src/ble/link.ts
Original file line number Diff line number Diff line change
@@ -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<Adapter> {
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<Link> {
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(() => {}); },
};
}
14 changes: 14 additions & 0 deletions apps/mobile/src/ble/permissions.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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);
}
18 changes: 18 additions & 0 deletions apps/mobile/src/fixtures/bytes.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
34 changes: 34 additions & 0 deletions apps/mobile/src/fixtures/bytes.ts
Original file line number Diff line number Diff line change
@@ -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);
}
61 changes: 61 additions & 0 deletions apps/mobile/src/fixtures/hub.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>) },
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";
}
}
Loading
Loading