@pickleball/expo-sdk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app.plugin.js +6 -0
- package/dist/chunk-VHLUJQG5.js +17 -0
- package/dist/index.cjs +6284 -0
- package/dist/index.d.cts +1436 -0
- package/dist/index.d.ts +1436 -0
- package/dist/index.js +6186 -0
- package/dist/plugin.cjs +153 -0
- package/dist/plugin.d.cts +21 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +131 -0
- package/dist/version.cjs +35 -0
- package/dist/version.d.cts +4 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.js +6 -0
- package/package.json +126 -0
- package/src/contracts.ts +211 -0
- package/src/engine.ts +1332 -0
- package/src/errors.ts +128 -0
- package/src/http-session-provider.ts +259 -0
- package/src/index.ts +16 -0
- package/src/livekit-room-adapter.ts +353 -0
- package/src/livestream-screen.tsx +980 -0
- package/src/match/contracts.ts +137 -0
- package/src/match/engine.ts +702 -0
- package/src/match/persistence/default-store.native.ts +20 -0
- package/src/match/persistence/default-store.ts +11 -0
- package/src/match/persistence/index.ts +6 -0
- package/src/match/persistence/memory-driver.ts +255 -0
- package/src/match/persistence/migrations.ts +40 -0
- package/src/match/persistence/sqlite-driver.ts +348 -0
- package/src/match/persistence/store.ts +497 -0
- package/src/match/persistence/types.ts +98 -0
- package/src/match/provider.tsx +728 -0
- package/src/match/remote/deduplicator.ts +54 -0
- package/src/match/remote/encoding.ts +12 -0
- package/src/match/remote/hid-gesture.ts +61 -0
- package/src/match/remote/index.ts +5 -0
- package/src/match/remote/native-service.ts +62 -0
- package/src/match/remote/parser.ts +134 -0
- package/src/match/remote/types.ts +116 -0
- package/src/match/replay.ts +102 -0
- package/src/match/state.ts +72 -0
- package/src/match/validation.ts +375 -0
- package/src/match/voice/assets/court-left.wav +0 -0
- package/src/match/voice/assets/court-right.wav +0 -0
- package/src/match/voice/assets/court-switch.wav +0 -0
- package/src/match/voice/assets/game-point.wav +0 -0
- package/src/match/voice/assets/game-won.wav +0 -0
- package/src/match/voice/assets/match-won.wav +0 -0
- package/src/match/voice/assets/number-0.wav +0 -0
- package/src/match/voice/assets/number-1.wav +0 -0
- package/src/match/voice/assets/number-10.wav +0 -0
- package/src/match/voice/assets/number-11.wav +0 -0
- package/src/match/voice/assets/number-12.wav +0 -0
- package/src/match/voice/assets/number-13.wav +0 -0
- package/src/match/voice/assets/number-14.wav +0 -0
- package/src/match/voice/assets/number-15.wav +0 -0
- package/src/match/voice/assets/number-16.wav +0 -0
- package/src/match/voice/assets/number-17.wav +0 -0
- package/src/match/voice/assets/number-18.wav +0 -0
- package/src/match/voice/assets/number-19.wav +0 -0
- package/src/match/voice/assets/number-2.wav +0 -0
- package/src/match/voice/assets/number-20.wav +0 -0
- package/src/match/voice/assets/number-3.wav +0 -0
- package/src/match/voice/assets/number-30.wav +0 -0
- package/src/match/voice/assets/number-4.wav +0 -0
- package/src/match/voice/assets/number-40.wav +0 -0
- package/src/match/voice/assets/number-5.wav +0 -0
- package/src/match/voice/assets/number-50.wav +0 -0
- package/src/match/voice/assets/number-6.wav +0 -0
- package/src/match/voice/assets/number-60.wav +0 -0
- package/src/match/voice/assets/number-7.wav +0 -0
- package/src/match/voice/assets/number-70.wav +0 -0
- package/src/match/voice/assets/number-8.wav +0 -0
- package/src/match/voice/assets/number-80.wav +0 -0
- package/src/match/voice/assets/number-9.wav +0 -0
- package/src/match/voice/assets/number-90.wav +0 -0
- package/src/match/voice/assets/number-lam.wav +0 -0
- package/src/match/voice/assets/number-mot.wav +0 -0
- package/src/match/voice/assets/score-deu-0.wav +0 -0
- package/src/match/voice/assets/score-deu-1.wav +0 -0
- package/src/match/voice/assets/score-deu-10.wav +0 -0
- package/src/match/voice/assets/score-deu-11.wav +0 -0
- package/src/match/voice/assets/score-deu-12.wav +0 -0
- package/src/match/voice/assets/score-deu-13.wav +0 -0
- package/src/match/voice/assets/score-deu-14.wav +0 -0
- package/src/match/voice/assets/score-deu-15.wav +0 -0
- package/src/match/voice/assets/score-deu-16.wav +0 -0
- package/src/match/voice/assets/score-deu-17.wav +0 -0
- package/src/match/voice/assets/score-deu-18.wav +0 -0
- package/src/match/voice/assets/score-deu-19.wav +0 -0
- package/src/match/voice/assets/score-deu-2.wav +0 -0
- package/src/match/voice/assets/score-deu-20.wav +0 -0
- package/src/match/voice/assets/score-deu-3.wav +0 -0
- package/src/match/voice/assets/score-deu-30.wav +0 -0
- package/src/match/voice/assets/score-deu-4.wav +0 -0
- package/src/match/voice/assets/score-deu-40.wav +0 -0
- package/src/match/voice/assets/score-deu-5.wav +0 -0
- package/src/match/voice/assets/score-deu-50.wav +0 -0
- package/src/match/voice/assets/score-deu-6.wav +0 -0
- package/src/match/voice/assets/score-deu-60.wav +0 -0
- package/src/match/voice/assets/score-deu-7.wav +0 -0
- package/src/match/voice/assets/score-deu-70.wav +0 -0
- package/src/match/voice/assets/score-deu-8.wav +0 -0
- package/src/match/voice/assets/score-deu-80.wav +0 -0
- package/src/match/voice/assets/score-deu-9.wav +0 -0
- package/src/match/voice/assets/score-deu-90.wav +0 -0
- package/src/match/voice/assets/score-deu-lam.wav +0 -0
- package/src/match/voice/assets/score-deu-mot.wav +0 -0
- package/src/match/voice/assets/server-1.wav +0 -0
- package/src/match/voice/assets/server-2.wav +0 -0
- package/src/match/voice/assets/service-change.wav +0 -0
- package/src/match/voice/assets/test-speaker.wav +0 -0
- package/src/match/voice/audio.ts +231 -0
- package/src/match/voice/bundled-assets.native.ts +47 -0
- package/src/match/voice/bundled-assets.ts +3 -0
- package/src/match/voice/gemini-engine.ts +167 -0
- package/src/match/voice/index.ts +7 -0
- package/src/match/voice/phrases.ts +185 -0
- package/src/match/voice/scheduler.ts +205 -0
- package/src/match/voice/service.ts +147 -0
- package/src/match/voice/types.ts +118 -0
- package/src/native-runtime.ts +241 -0
- package/src/plugin.ts +145 -0
- package/src/provider.tsx +176 -0
- package/src/version.ts +4 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface RemoteDeduplicationKey {
|
|
2
|
+
readonly packetId?: number;
|
|
3
|
+
readonly fingerprint: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface BTHomeRemoteDeduplicatorOptions {
|
|
7
|
+
readonly fingerprintWindowMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DeviceDeduplicationState {
|
|
11
|
+
packetId?: number;
|
|
12
|
+
fingerprint?: string;
|
|
13
|
+
fingerprintSeenAt?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class BTHomeRemoteDeduplicator {
|
|
17
|
+
private readonly devices = new Map<string, DeviceDeduplicationState>();
|
|
18
|
+
private readonly fingerprintWindowMs: number;
|
|
19
|
+
|
|
20
|
+
constructor(options: BTHomeRemoteDeduplicatorOptions = {}) {
|
|
21
|
+
this.fingerprintWindowMs = options.fingerprintWindowMs ?? 300;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get trackedDeviceCount(): number {
|
|
25
|
+
return this.devices.size;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
accept(deviceId: string, key: RemoteDeduplicationKey, now = Date.now()): boolean {
|
|
29
|
+
const previous = this.devices.get(deviceId);
|
|
30
|
+
if (key.packetId !== undefined) {
|
|
31
|
+
if (previous?.packetId === key.packetId) return false;
|
|
32
|
+
this.devices.set(deviceId, { packetId: key.packetId });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
previous?.fingerprint === key.fingerprint &&
|
|
37
|
+
previous.fingerprintSeenAt !== undefined &&
|
|
38
|
+
now - previous.fingerprintSeenAt <= this.fingerprintWindowMs
|
|
39
|
+
) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
this.devices.set(deviceId, { fingerprint: key.fingerprint, fingerprintSeenAt: now });
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
forget(deviceId: string): void {
|
|
47
|
+
this.devices.delete(deviceId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(): void {
|
|
51
|
+
this.devices.clear();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function decodeBase64Bytes(value: string): Uint8Array {
|
|
2
|
+
if (typeof globalThis.atob !== "function") {
|
|
3
|
+
throw new Error("Base64 decoding is unavailable in this runtime");
|
|
4
|
+
}
|
|
5
|
+
const binary = globalThis.atob(value);
|
|
6
|
+
const bytes = new Uint8Array(binary.length);
|
|
7
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
8
|
+
bytes[index] = binary.charCodeAt(index);
|
|
9
|
+
}
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
|
12
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { RemoteHidGesture, RemoteHidGestureMapping, RemoteHidVolumeEvent } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface HidVolumeGestureRecognizerOptions extends Pick<
|
|
4
|
+
RemoteHidGestureMapping,
|
|
5
|
+
"direction" | "settleMs" | "holdEventCount"
|
|
6
|
+
> {
|
|
7
|
+
readonly onGesture: (gesture: RemoteHidGesture) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class HidVolumeGestureRecognizer {
|
|
11
|
+
private count = 0;
|
|
12
|
+
private resolved = false;
|
|
13
|
+
private lastTimestamp: number | null = null;
|
|
14
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
15
|
+
|
|
16
|
+
constructor(private readonly options: HidVolumeGestureRecognizerOptions) {}
|
|
17
|
+
|
|
18
|
+
push(event: RemoteHidVolumeEvent) {
|
|
19
|
+
if (event.direction !== this.options.direction) return;
|
|
20
|
+
if (
|
|
21
|
+
this.lastTimestamp !== null &&
|
|
22
|
+
event.timestamp - this.lastTimestamp > this.options.settleMs
|
|
23
|
+
) {
|
|
24
|
+
this.finishBurst();
|
|
25
|
+
}
|
|
26
|
+
this.lastTimestamp = event.timestamp;
|
|
27
|
+
this.clearTimer();
|
|
28
|
+
if (!this.resolved) {
|
|
29
|
+
this.count += 1;
|
|
30
|
+
if (event.longPress || this.count >= this.options.holdEventCount) {
|
|
31
|
+
this.resolved = true;
|
|
32
|
+
this.options.onGesture("hold");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
this.timer = setTimeout(() => this.finishBurst(), this.options.settleMs);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
dispose() {
|
|
39
|
+
this.clearTimer();
|
|
40
|
+
this.reset();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private finishBurst() {
|
|
44
|
+
this.clearTimer();
|
|
45
|
+
if (!this.resolved && this.count > 0) {
|
|
46
|
+
this.options.onGesture(this.count === 1 ? "single" : "double");
|
|
47
|
+
}
|
|
48
|
+
this.reset();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private clearTimer() {
|
|
52
|
+
if (this.timer) clearTimeout(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private reset() {
|
|
57
|
+
this.count = 0;
|
|
58
|
+
this.resolved = false;
|
|
59
|
+
this.lastTimestamp = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RemoteError, type RemoteHidVolumeEvent, type RemoteNativeState, type RemoteScanAdvertisement, type RemoteService } from "./types";
|
|
2
|
+
|
|
3
|
+
interface NativeSubscription { remove(): void }
|
|
4
|
+
interface RemoteNativeModule {
|
|
5
|
+
start(): Promise<void>;
|
|
6
|
+
stop(): Promise<void>;
|
|
7
|
+
destroy(): Promise<void>;
|
|
8
|
+
addListener(name: "onAdvertisement", listener: (event: RemoteScanAdvertisement) => void): NativeSubscription;
|
|
9
|
+
addListener(name: "onStateChanged", listener: (event: RemoteNativeState) => void): NativeSubscription;
|
|
10
|
+
addListener(name: "onHidVolume", listener: (event: RemoteHidVolumeEvent) => void): NativeSubscription;
|
|
11
|
+
startHidCapture(options?: { consume?: boolean }): Promise<void>;
|
|
12
|
+
stopHidCapture(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
declare const require: (name: string) => any;
|
|
16
|
+
|
|
17
|
+
function loadNative(): RemoteNativeModule {
|
|
18
|
+
const imported = require("@pickleball/remote-native");
|
|
19
|
+
return (imported.default ?? imported) as RemoteNativeModule;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createDefaultRemoteService(): RemoteService {
|
|
23
|
+
let native: RemoteNativeModule | null = null;
|
|
24
|
+
let disposed = false;
|
|
25
|
+
const getNative = () => {
|
|
26
|
+
if (disposed) throw new RemoteError("native_unavailable", "Remote scanner is disposed");
|
|
27
|
+
try {
|
|
28
|
+
native ??= loadNative();
|
|
29
|
+
return native;
|
|
30
|
+
} catch (cause) {
|
|
31
|
+
throw new RemoteError(
|
|
32
|
+
"native_unavailable",
|
|
33
|
+
"BTHome scanning requires an iOS or Android build with @pickleball/remote-native",
|
|
34
|
+
{ cause },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
start: () => getNative().start(),
|
|
40
|
+
async stop() { await native?.stop(); },
|
|
41
|
+
async dispose() {
|
|
42
|
+
if (disposed) return;
|
|
43
|
+
disposed = true;
|
|
44
|
+
await native?.destroy();
|
|
45
|
+
native = null;
|
|
46
|
+
},
|
|
47
|
+
subscribeAdvertisement(listener) {
|
|
48
|
+
const subscription = getNative().addListener("onAdvertisement", listener);
|
|
49
|
+
return () => subscription.remove();
|
|
50
|
+
},
|
|
51
|
+
subscribeState(listener) {
|
|
52
|
+
const subscription = getNative().addListener("onStateChanged", listener);
|
|
53
|
+
return () => subscription.remove();
|
|
54
|
+
},
|
|
55
|
+
startHidCapture: (options) => getNative().startHidCapture(options),
|
|
56
|
+
async stopHidCapture() { await native?.stopHidCapture(); },
|
|
57
|
+
subscribeHidVolume(listener) {
|
|
58
|
+
const subscription = getNative().addListener("onHidVolume", listener);
|
|
59
|
+
return () => subscription.remove();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { BTHOME_SERVICE_UUID, type RemoteButtonAction } from "./types";
|
|
2
|
+
|
|
3
|
+
export type BTHomeRemoteParseErrorCode =
|
|
4
|
+
| "wrong_uuid"
|
|
5
|
+
| "unsupported_version"
|
|
6
|
+
| "encrypted"
|
|
7
|
+
| "truncated"
|
|
8
|
+
| "unknown_object"
|
|
9
|
+
| "invalid_button_event"
|
|
10
|
+
| "no_button";
|
|
11
|
+
|
|
12
|
+
export class BTHomeRemoteParseError extends Error {
|
|
13
|
+
readonly name = "BTHomeRemoteParseError";
|
|
14
|
+
constructor(readonly code: BTHomeRemoteParseErrorCode, message: string) {
|
|
15
|
+
super(message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BTHomeAdvertisementInput {
|
|
20
|
+
readonly deviceId: string;
|
|
21
|
+
readonly serviceUuid: string;
|
|
22
|
+
/** FCD2 service data only. The first byte must be BTHome device-info, not UUID bytes. */
|
|
23
|
+
readonly serviceData: Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ParsedBTHomeRemoteAdvertisement {
|
|
27
|
+
readonly deviceId: string;
|
|
28
|
+
readonly version: 2;
|
|
29
|
+
readonly encrypted: false;
|
|
30
|
+
readonly triggerBased: boolean;
|
|
31
|
+
readonly packetId?: number;
|
|
32
|
+
readonly button: {
|
|
33
|
+
readonly action: RemoteButtonAction;
|
|
34
|
+
readonly objectIndex: number;
|
|
35
|
+
readonly rawEvent: number;
|
|
36
|
+
};
|
|
37
|
+
readonly fingerprint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* Fixed BTHome v2 object lengths. Keeping the registry explicit is important:
|
|
42
|
+
* unknown object values are never searched for a coincidental 0x3a byte.
|
|
43
|
+
* It covers the environmental, binary-sensor, event, count and distance
|
|
44
|
+
* objects commonly emitted by BTHome remotes and sensors.
|
|
45
|
+
*/
|
|
46
|
+
const OBJECT_LENGTHS: Readonly<Record<number, number>> = Object.freeze({
|
|
47
|
+
0x00: 1, 0x01: 1, 0x02: 2, 0x03: 2, 0x04: 3, 0x05: 3,
|
|
48
|
+
0x06: 2, 0x07: 2, 0x08: 2, 0x09: 1, 0x0a: 3, 0x0b: 3,
|
|
49
|
+
0x0c: 2, 0x0d: 2, 0x0e: 2, 0x0f: 1, 0x10: 1, 0x11: 1,
|
|
50
|
+
0x12: 2, 0x13: 2, 0x14: 2, 0x15: 1, 0x16: 1, 0x17: 1,
|
|
51
|
+
0x18: 1, 0x19: 1, 0x1a: 1, 0x1b: 1, 0x1c: 1, 0x1d: 1,
|
|
52
|
+
0x1e: 1, 0x1f: 1, 0x20: 1, 0x21: 1, 0x22: 1, 0x23: 1,
|
|
53
|
+
0x24: 1, 0x25: 1, 0x26: 1, 0x27: 1, 0x28: 1, 0x29: 1,
|
|
54
|
+
0x2a: 1, 0x2b: 1, 0x2c: 1, 0x2d: 1, 0x2e: 1, 0x2f: 1,
|
|
55
|
+
0x3a: 1, 0x3c: 2, 0x3d: 2, 0x3e: 4, 0x3f: 2, 0x40: 1,
|
|
56
|
+
0x41: 1, 0x42: 1, 0x43: 1, 0x44: 2, 0x45: 2, 0x46: 4,
|
|
57
|
+
0x47: 4, 0x48: 4, 0x49: 2, 0x4a: 2, 0x4b: 2, 0x4c: 4,
|
|
58
|
+
0x4d: 4, 0x4e: 4, 0x4f: 4,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function isBTHomeUuid(uuid: string): boolean {
|
|
62
|
+
const normalized = uuid.toLowerCase().replaceAll("-", "");
|
|
63
|
+
return normalized === "fcd2" || normalized === "0000fcd200001000800000805f9b34fb";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fingerprint(bytes: Uint8Array): string {
|
|
67
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mapButtonEvent(value: number): RemoteButtonAction | null | undefined {
|
|
71
|
+
if (value === 0x01) return "single";
|
|
72
|
+
if (value === 0x02) return "double";
|
|
73
|
+
if (value === 0x04 || value === 0x80) return "long";
|
|
74
|
+
if (value === 0x00 || value === 0x03 || value === 0x05 || value === 0x06) return null;
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseBTHomeV2Advertisement(
|
|
79
|
+
input: BTHomeAdvertisementInput,
|
|
80
|
+
): ParsedBTHomeRemoteAdvertisement {
|
|
81
|
+
if (!isBTHomeUuid(input.serviceUuid)) {
|
|
82
|
+
throw new BTHomeRemoteParseError("wrong_uuid", `Expected ${BTHOME_SERVICE_UUID} service data`);
|
|
83
|
+
}
|
|
84
|
+
if (input.serviceData.length < 1) {
|
|
85
|
+
throw new BTHomeRemoteParseError("truncated", "BTHome device-info byte is missing");
|
|
86
|
+
}
|
|
87
|
+
const info = input.serviceData[0]!;
|
|
88
|
+
const version = (info >> 5) & 0x07;
|
|
89
|
+
if (version !== 2) {
|
|
90
|
+
throw new BTHomeRemoteParseError("unsupported_version", `Unsupported BTHome version ${version}`);
|
|
91
|
+
}
|
|
92
|
+
if ((info & 0x01) !== 0) {
|
|
93
|
+
throw new BTHomeRemoteParseError("encrypted", "Encrypted BTHome advertisements are unsupported");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let offset = 1;
|
|
97
|
+
let objectIndex = 0;
|
|
98
|
+
let packetId: number | undefined;
|
|
99
|
+
let button: ParsedBTHomeRemoteAdvertisement["button"] | undefined;
|
|
100
|
+
while (offset < input.serviceData.length) {
|
|
101
|
+
const objectId = input.serviceData[offset++]!;
|
|
102
|
+
const length = OBJECT_LENGTHS[objectId];
|
|
103
|
+
if (length === undefined) {
|
|
104
|
+
throw new BTHomeRemoteParseError("unknown_object", `Unknown BTHome object 0x${objectId.toString(16)}`);
|
|
105
|
+
}
|
|
106
|
+
if (offset + length > input.serviceData.length) {
|
|
107
|
+
throw new BTHomeRemoteParseError("truncated", `BTHome object 0x${objectId.toString(16)} is truncated`);
|
|
108
|
+
}
|
|
109
|
+
if (objectId === 0x00 && packetId === undefined) packetId = input.serviceData[offset];
|
|
110
|
+
if (objectId === 0x3a && !button) {
|
|
111
|
+
const rawEvent = input.serviceData[offset]!;
|
|
112
|
+
const action = mapButtonEvent(rawEvent);
|
|
113
|
+
if (action === undefined) {
|
|
114
|
+
throw new BTHomeRemoteParseError("invalid_button_event", `Reserved button event 0x${rawEvent.toString(16)}`);
|
|
115
|
+
}
|
|
116
|
+
if (action) button = { action, objectIndex, rawEvent };
|
|
117
|
+
}
|
|
118
|
+
offset += length;
|
|
119
|
+
objectIndex += 1;
|
|
120
|
+
}
|
|
121
|
+
if (!button) {
|
|
122
|
+
throw new BTHomeRemoteParseError("no_button", "No actionable BTHome button event was found");
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
deviceId: input.deviceId,
|
|
126
|
+
version: 2,
|
|
127
|
+
encrypted: false,
|
|
128
|
+
triggerBased: (info & 0x04) !== 0,
|
|
129
|
+
packetId,
|
|
130
|
+
button,
|
|
131
|
+
fingerprint: fingerprint(input.serviceData),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export const BTHOME_SERVICE_UUID = "FCD2" as const;
|
|
2
|
+
|
|
3
|
+
export type RemoteButtonAction = "single" | "double" | "long";
|
|
4
|
+
export type RemoteInputMode = "bthome" | "hidVolume" | "both";
|
|
5
|
+
export type RemoteMappedAction = "left" | "right" | "undo" | "none";
|
|
6
|
+
export interface RemoteActionMapping {
|
|
7
|
+
readonly volumeUp: RemoteMappedAction;
|
|
8
|
+
readonly volumeDown: RemoteMappedAction;
|
|
9
|
+
readonly longPress: RemoteMappedAction;
|
|
10
|
+
}
|
|
11
|
+
export const DEFAULT_REMOTE_ACTION_MAPPING: RemoteActionMapping = {
|
|
12
|
+
volumeUp: "left", volumeDown: "right", longPress: "undo",
|
|
13
|
+
};
|
|
14
|
+
export interface RemoteHidVolumeEvent {
|
|
15
|
+
readonly source: "hidVolume";
|
|
16
|
+
readonly direction: "volumeUp" | "volumeDown";
|
|
17
|
+
readonly repeat: boolean;
|
|
18
|
+
readonly longPress: boolean;
|
|
19
|
+
readonly timestamp: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RemoteHidGesture = "single" | "double" | "hold";
|
|
23
|
+
|
|
24
|
+
export interface RemoteHidGestureMapping {
|
|
25
|
+
/** Raw HID direction emitted by the calibrated remote button. */
|
|
26
|
+
readonly direction: RemoteHidVolumeEvent["direction"];
|
|
27
|
+
/** Quiet window used to close one physical gesture burst. */
|
|
28
|
+
readonly settleMs: number;
|
|
29
|
+
/** Repeated volume changes required to classify a hold before the burst ends. */
|
|
30
|
+
readonly holdEventCount: number;
|
|
31
|
+
readonly single: RemoteMappedAction;
|
|
32
|
+
readonly double: RemoteMappedAction;
|
|
33
|
+
readonly hold: RemoteMappedAction;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RemoteBinding {
|
|
37
|
+
/** Stable identifier supplied by CoreBluetooth or BluetoothLeScanner. */
|
|
38
|
+
readonly deviceId: string;
|
|
39
|
+
readonly label?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type RemotePermissionStatus =
|
|
43
|
+
| "unknown"
|
|
44
|
+
| "granted"
|
|
45
|
+
| "denied"
|
|
46
|
+
| "restricted"
|
|
47
|
+
| "unavailable";
|
|
48
|
+
|
|
49
|
+
export type RemoteStatus =
|
|
50
|
+
| "unavailable"
|
|
51
|
+
| "poweredOff"
|
|
52
|
+
| "idle"
|
|
53
|
+
| "scanning"
|
|
54
|
+
| "bound"
|
|
55
|
+
| "outOfRange";
|
|
56
|
+
|
|
57
|
+
export interface RemoteCandidate {
|
|
58
|
+
readonly deviceId: string;
|
|
59
|
+
readonly label?: string;
|
|
60
|
+
readonly rssi: number;
|
|
61
|
+
readonly lastSeenAt: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RemoteState {
|
|
65
|
+
readonly status: RemoteStatus;
|
|
66
|
+
readonly permission: RemotePermissionStatus;
|
|
67
|
+
readonly binding: RemoteBinding | null;
|
|
68
|
+
readonly candidate: RemoteCandidate | null;
|
|
69
|
+
readonly lastSeenAt: number | null;
|
|
70
|
+
readonly error: RemoteError | null;
|
|
71
|
+
readonly hidCaptureArmed: boolean;
|
|
72
|
+
readonly lastInputSource: "bthome" | "hidVolume" | null;
|
|
73
|
+
readonly lastHidEvent: RemoteHidVolumeEvent | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type RemoteErrorCode =
|
|
77
|
+
| "permission_denied"
|
|
78
|
+
| "permission_restricted"
|
|
79
|
+
| "bluetooth_unavailable"
|
|
80
|
+
| "bluetooth_powered_off"
|
|
81
|
+
| "scan_failed"
|
|
82
|
+
| "native_unavailable";
|
|
83
|
+
|
|
84
|
+
export class RemoteError extends Error {
|
|
85
|
+
readonly name = "RemoteError";
|
|
86
|
+
|
|
87
|
+
constructor(readonly code: RemoteErrorCode, message: string, options?: { cause?: unknown }) {
|
|
88
|
+
super(message, options);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface RemoteScanAdvertisement {
|
|
93
|
+
readonly deviceId: string;
|
|
94
|
+
readonly name?: string;
|
|
95
|
+
readonly rssi: number;
|
|
96
|
+
readonly serviceUuid: string;
|
|
97
|
+
/** Base64 encoded FCD2 service-data payload, beginning with the device-info byte. */
|
|
98
|
+
readonly serviceData: string;
|
|
99
|
+
readonly timestamp: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type RemoteNativeState =
|
|
103
|
+
| { readonly status: "permission"; readonly permission: RemotePermissionStatus }
|
|
104
|
+
| { readonly status: "poweredOff" | "unavailable" | "scanning" | "idle" }
|
|
105
|
+
| { readonly status: "error"; readonly code: string; readonly message: string };
|
|
106
|
+
|
|
107
|
+
export interface RemoteService {
|
|
108
|
+
start(): Promise<void>;
|
|
109
|
+
stop(): Promise<void>;
|
|
110
|
+
dispose(): Promise<void>;
|
|
111
|
+
subscribeAdvertisement(listener: (event: RemoteScanAdvertisement) => void): () => void;
|
|
112
|
+
subscribeState(listener: (event: RemoteNativeState) => void): () => void;
|
|
113
|
+
startHidCapture(options?: { consume?: boolean }): Promise<void>;
|
|
114
|
+
stopHidCapture(): Promise<void>;
|
|
115
|
+
subscribeHidVolume(listener: (event: RemoteHidVolumeEvent) => void): () => void;
|
|
116
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MatchEvent,
|
|
3
|
+
MatchEventUndoneEvent,
|
|
4
|
+
MatchInputSource,
|
|
5
|
+
MatchSnapshot,
|
|
6
|
+
} from "./contracts";
|
|
7
|
+
import { applyMatchEvent, createMatch } from "./engine";
|
|
8
|
+
import { cloneSnapshot, valuesEqual } from "./state";
|
|
9
|
+
import { assertRuntimeMatchEvent } from "./validation";
|
|
10
|
+
|
|
11
|
+
interface EffectiveEvent {
|
|
12
|
+
event: Exclude<MatchEvent, MatchEventUndoneEvent>;
|
|
13
|
+
before: MatchSnapshot;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ReplayTrace {
|
|
17
|
+
snapshot: MatchSnapshot;
|
|
18
|
+
effectiveEvents: EffectiveEvent[];
|
|
19
|
+
processedEvents: number;
|
|
20
|
+
undoValidations: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function replayTrace(events: readonly unknown[]): ReplayTrace {
|
|
24
|
+
const [rawCreated, ...remaining] = events;
|
|
25
|
+
assertRuntimeMatchEvent(rawCreated);
|
|
26
|
+
if (rawCreated.type !== "matchCreated" || rawCreated.sequence !== 0) {
|
|
27
|
+
throw new TypeError("A match event log must begin with its creation event");
|
|
28
|
+
}
|
|
29
|
+
const created = rawCreated;
|
|
30
|
+
if (created.id !== `${created.snapshot.id}:0`) {
|
|
31
|
+
throw new TypeError("Creation event id does not match its snapshot");
|
|
32
|
+
}
|
|
33
|
+
const expected = createMatch(
|
|
34
|
+
{
|
|
35
|
+
id: created.snapshot.id,
|
|
36
|
+
format: created.snapshot.format,
|
|
37
|
+
scoringMode: created.snapshot.scoringMode,
|
|
38
|
+
targetScore: created.snapshot.targetScore,
|
|
39
|
+
initialServingTeam: created.snapshot.initialServingTeam,
|
|
40
|
+
teams: created.snapshot.teams,
|
|
41
|
+
},
|
|
42
|
+
created.source,
|
|
43
|
+
).snapshot;
|
|
44
|
+
if (!valuesEqual(expected, created.snapshot)) {
|
|
45
|
+
throw new TypeError("Creation snapshot is not a valid initial match state");
|
|
46
|
+
}
|
|
47
|
+
let snapshot = cloneSnapshot(created.snapshot);
|
|
48
|
+
const effectiveEvents: EffectiveEvent[] = [];
|
|
49
|
+
let processedEvents = 1;
|
|
50
|
+
let undoValidations = 0;
|
|
51
|
+
|
|
52
|
+
for (const rawEvent of remaining) {
|
|
53
|
+
assertRuntimeMatchEvent(rawEvent);
|
|
54
|
+
const event = rawEvent;
|
|
55
|
+
if (event.type === "matchEventUndone") {
|
|
56
|
+
const target = effectiveEvents.at(-1);
|
|
57
|
+
if (!target || target.event.id !== event.targetEventId) {
|
|
58
|
+
throw new TypeError("Undo does not target the latest effective match event");
|
|
59
|
+
}
|
|
60
|
+
if (!valuesEqual(target.before, event.restoredSnapshot)) {
|
|
61
|
+
throw new TypeError("Undo snapshot does not match its event lineage");
|
|
62
|
+
}
|
|
63
|
+
effectiveEvents.pop();
|
|
64
|
+
undoValidations += 1;
|
|
65
|
+
} else {
|
|
66
|
+
effectiveEvents.push({ event, before: cloneSnapshot(snapshot) });
|
|
67
|
+
}
|
|
68
|
+
snapshot = applyMatchEvent(snapshot, event);
|
|
69
|
+
processedEvents += 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { snapshot, effectiveEvents, processedEvents, undoValidations };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function replayMatchEvents(events: readonly unknown[]): MatchSnapshot {
|
|
76
|
+
return replayTrace(events).snapshot;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Internal deterministic complexity signal used by reducer regression tests. */
|
|
80
|
+
export function replayMatchEventsWithStats(events: readonly unknown[]) {
|
|
81
|
+
const { snapshot, processedEvents, undoValidations } = replayTrace(events);
|
|
82
|
+
return { snapshot, processedEvents, undoValidations };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function undoLastMatchEvent(
|
|
86
|
+
events: readonly MatchEvent[],
|
|
87
|
+
source: MatchInputSource = "local",
|
|
88
|
+
): MatchEventUndoneEvent | null {
|
|
89
|
+
const trace = replayTrace(events);
|
|
90
|
+
const target = trace.effectiveEvents.at(-1);
|
|
91
|
+
if (!target) return null;
|
|
92
|
+
const sequence = trace.snapshot.sequence + 1;
|
|
93
|
+
return {
|
|
94
|
+
type: "matchEventUndone",
|
|
95
|
+
schemaVersion: 1,
|
|
96
|
+
id: `${trace.snapshot.id}:${sequence}`,
|
|
97
|
+
sequence,
|
|
98
|
+
source,
|
|
99
|
+
targetEventId: target.event.id,
|
|
100
|
+
restoredSnapshot: cloneSnapshot(target.before),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GameSnapshot,
|
|
3
|
+
MatchPlayer,
|
|
4
|
+
MatchSnapshot,
|
|
5
|
+
MatchTeam,
|
|
6
|
+
} from "./contracts";
|
|
7
|
+
|
|
8
|
+
function clonePlayer(player: MatchPlayer): MatchPlayer {
|
|
9
|
+
return { id: player.id, name: player.name };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function cloneTeam(team: MatchTeam): MatchTeam {
|
|
13
|
+
return {
|
|
14
|
+
id: team.id,
|
|
15
|
+
...(team.name === undefined ? {} : { name: team.name }),
|
|
16
|
+
...(team.initialServerId === undefined
|
|
17
|
+
? {}
|
|
18
|
+
: { initialServerId: team.initialServerId }),
|
|
19
|
+
players: team.players.map(clonePlayer),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function cloneGame(game: GameSnapshot): GameSnapshot {
|
|
24
|
+
return { ...game, score: { ...game.score } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function cloneSnapshot(snapshot: MatchSnapshot): MatchSnapshot {
|
|
28
|
+
return {
|
|
29
|
+
...snapshot,
|
|
30
|
+
teams: { A: cloneTeam(snapshot.teams.A), B: cloneTeam(snapshot.teams.B) },
|
|
31
|
+
designatedServerByTeam: { ...snapshot.designatedServerByTeam },
|
|
32
|
+
courtSides: { ...snapshot.courtSides },
|
|
33
|
+
playerCourts: { ...snapshot.playerCourts },
|
|
34
|
+
games: snapshot.games.map(cloneGame),
|
|
35
|
+
gamesWon: { ...snapshot.gamesWon },
|
|
36
|
+
serve: { ...snapshot.serve },
|
|
37
|
+
pendingCourtSwitch: snapshot.pendingCourtSwitch
|
|
38
|
+
? { ...snapshot.pendingCourtSwitch }
|
|
39
|
+
: null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function valuesEqual(left: unknown, right: unknown): boolean {
|
|
44
|
+
if (left === right) return true;
|
|
45
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
46
|
+
return (
|
|
47
|
+
Array.isArray(left) &&
|
|
48
|
+
Array.isArray(right) &&
|
|
49
|
+
left.length === right.length &&
|
|
50
|
+
left.every((value, index) => valuesEqual(value, right[index]))
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
typeof left !== "object" ||
|
|
55
|
+
left === null ||
|
|
56
|
+
typeof right !== "object" ||
|
|
57
|
+
right === null
|
|
58
|
+
) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
const leftRecord = left as Record<string, unknown>;
|
|
62
|
+
const rightRecord = right as Record<string, unknown>;
|
|
63
|
+
const leftKeys = Object.keys(leftRecord).sort();
|
|
64
|
+
const rightKeys = Object.keys(rightRecord).sort();
|
|
65
|
+
return (
|
|
66
|
+
leftKeys.length === rightKeys.length &&
|
|
67
|
+
leftKeys.every(
|
|
68
|
+
(key, index) =>
|
|
69
|
+
key === rightKeys[index] && valuesEqual(leftRecord[key], rightRecord[key]),
|
|
70
|
+
)
|
|
71
|
+
);
|
|
72
|
+
}
|