@pickleball/expo-sdk 0.2.0 → 1.0.0
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 +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- package/src/livestream-screen.tsx +0 -1002
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codec MQTT 3.1.1 tối giản — đủ cho device plane, KHÔNG phụ thuộc thư viện.
|
|
3
|
+
*
|
|
4
|
+
* Vì sao tự viết thay vì lấy `mqtt`/`paho`:
|
|
5
|
+
* - App RN bắt buộc đi WebSocket (TCP thuần = native module = mất OTA). `mqtt`
|
|
6
|
+
* npm cần polyfill buffer/process/stream/url — repo này đang 0 polyfill, thêm
|
|
7
|
+
* vào là phình bundle và phải test lại trên Hermes. `paho-mqtt` thì lưu QoS
|
|
8
|
+
* bằng `localStorage`, thứ không tồn tại trong RN.
|
|
9
|
+
* - Phần giao thức mà device plane dùng rất hẹp: CONNECT (kèm LWT), SUBSCRIBE,
|
|
10
|
+
* PUBLISH QoS0/1, PINGREQ, DISCONNECT. Chừng đó gói vừa đúng một file đọc
|
|
11
|
+
* được, và đổi lại ta kiểm soát hoàn toàn hành vi lúc mạng chập chờn.
|
|
12
|
+
*
|
|
13
|
+
* File này CHỈ mã hoá/giải mã — không đụng WebSocket, không timer, không state.
|
|
14
|
+
* Nhờ vậy test được bằng dữ liệu thuần (xem test/mqtt-codec.test.ts), còn vòng
|
|
15
|
+
* đời kết nối nằm ở mqtt-channel.ts.
|
|
16
|
+
*
|
|
17
|
+
* Tham chiếu: OASIS MQTT 3.1.1, mục 2 (khung gói) và 3 (từng loại gói).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ===== Loại gói (4 bit cao của byte đầu) =====
|
|
21
|
+
export const PACKET = {
|
|
22
|
+
CONNECT: 1,
|
|
23
|
+
CONNACK: 2,
|
|
24
|
+
PUBLISH: 3,
|
|
25
|
+
PUBACK: 4,
|
|
26
|
+
SUBSCRIBE: 8,
|
|
27
|
+
SUBACK: 9,
|
|
28
|
+
PINGREQ: 12,
|
|
29
|
+
PINGRESP: 13,
|
|
30
|
+
DISCONNECT: 14,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** Mã trả về của CONNACK — 0 là chấp nhận, còn lại là từ chối có lý do. */
|
|
34
|
+
export const CONNACK_REASON: Record<number, string> = {
|
|
35
|
+
0: "chấp nhận",
|
|
36
|
+
1: "sai phiên bản giao thức",
|
|
37
|
+
2: "clientId bị từ chối",
|
|
38
|
+
3: "broker không sẵn sàng",
|
|
39
|
+
4: "sai username/password",
|
|
40
|
+
5: "không được phép (auth hook từ chối)",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface MqttWill {
|
|
44
|
+
topic: string;
|
|
45
|
+
payload: string;
|
|
46
|
+
qos: 0 | 1;
|
|
47
|
+
retain: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConnectOptions {
|
|
51
|
+
clientId: string;
|
|
52
|
+
username?: string;
|
|
53
|
+
password?: string;
|
|
54
|
+
/** Giây. 0 = tắt keepalive (không dùng ở đây — LWT dựa vào nhịp này). */
|
|
55
|
+
keepaliveS: number;
|
|
56
|
+
will?: MqttWill;
|
|
57
|
+
/**
|
|
58
|
+
* `false` = broker giữ session (subscription sống qua lần rớt). Device plane
|
|
59
|
+
* dùng `true`: máy tự subscribe lại sau mỗi lần nối, và session cũ còn treo
|
|
60
|
+
* chỉ tổ giữ lệnh cũ QoS1 rồi dội lại lúc reconnect — thứ ta KHÔNG muốn.
|
|
61
|
+
*/
|
|
62
|
+
cleanSession: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ===== Ghi số/chuỗi =====
|
|
66
|
+
|
|
67
|
+
/** UTF-8 tự viết: TextEncoder không chắc có trên mọi bản Hermes. */
|
|
68
|
+
export function utf8Encode(s: string): Uint8Array {
|
|
69
|
+
const out: number[] = [];
|
|
70
|
+
for (let i = 0; i < s.length; i++) {
|
|
71
|
+
let code = s.charCodeAt(i);
|
|
72
|
+
// Ghép cặp surrogate (emoji, ký tự ngoài BMP) trước khi mã hoá.
|
|
73
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) {
|
|
74
|
+
const next = s.charCodeAt(i + 1);
|
|
75
|
+
if (next >= 0xdc00 && next <= 0xdfff) {
|
|
76
|
+
code = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (code < 0x80) out.push(code);
|
|
81
|
+
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 63));
|
|
82
|
+
else if (code < 0x10000)
|
|
83
|
+
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
|
|
84
|
+
else
|
|
85
|
+
out.push(
|
|
86
|
+
0xf0 | (code >> 18),
|
|
87
|
+
0x80 | ((code >> 12) & 63),
|
|
88
|
+
0x80 | ((code >> 6) & 63),
|
|
89
|
+
0x80 | (code & 63),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return Uint8Array.from(out);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function utf8Decode(bytes: Uint8Array): string {
|
|
96
|
+
let out = "";
|
|
97
|
+
for (let i = 0; i < bytes.length; ) {
|
|
98
|
+
const b = bytes[i]!;
|
|
99
|
+
let code: number;
|
|
100
|
+
if (b < 0x80) {
|
|
101
|
+
code = b;
|
|
102
|
+
i += 1;
|
|
103
|
+
} else if (b < 0xe0) {
|
|
104
|
+
code = ((b & 31) << 6) | (bytes[i + 1]! & 63);
|
|
105
|
+
i += 2;
|
|
106
|
+
} else if (b < 0xf0) {
|
|
107
|
+
code = ((b & 15) << 12) | ((bytes[i + 1]! & 63) << 6) | (bytes[i + 2]! & 63);
|
|
108
|
+
i += 3;
|
|
109
|
+
} else {
|
|
110
|
+
code =
|
|
111
|
+
((b & 7) << 18) |
|
|
112
|
+
((bytes[i + 1]! & 63) << 12) |
|
|
113
|
+
((bytes[i + 2]! & 63) << 6) |
|
|
114
|
+
(bytes[i + 3]! & 63);
|
|
115
|
+
i += 4;
|
|
116
|
+
}
|
|
117
|
+
if (code > 0xffff) {
|
|
118
|
+
code -= 0x10000;
|
|
119
|
+
out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 1023));
|
|
120
|
+
} else {
|
|
121
|
+
out += String.fromCharCode(code);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Chuỗi trong MQTT luôn có 2 byte độ dài đứng trước (big-endian). */
|
|
128
|
+
function encodeString(s: string): number[] {
|
|
129
|
+
const bytes = utf8Encode(s);
|
|
130
|
+
if (bytes.length > 0xffff) throw new Error("Chuỗi MQTT vượt 65535 byte");
|
|
131
|
+
return [bytes.length >> 8, bytes.length & 0xff, ...bytes];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* "Remaining Length" là varint 7-bit, tối đa 4 byte (256MB). Đây là chỗ dễ sai
|
|
136
|
+
* nhất của MQTT: nhiều bản tự viết chỉ xử lý 1 byte rồi vỡ khi payload > 127.
|
|
137
|
+
*/
|
|
138
|
+
export function encodeRemainingLength(n: number): number[] {
|
|
139
|
+
if (n < 0 || n > 268_435_455) throw new Error(`Độ dài MQTT không hợp lệ: ${n}`);
|
|
140
|
+
const out: number[] = [];
|
|
141
|
+
let value = n;
|
|
142
|
+
do {
|
|
143
|
+
let byte = value % 128;
|
|
144
|
+
value = Math.floor(value / 128);
|
|
145
|
+
if (value > 0) byte |= 128;
|
|
146
|
+
out.push(byte);
|
|
147
|
+
} while (value > 0);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Trả `null` khi chưa đủ byte để đọc hết varint (gói còn dở trong buffer). */
|
|
152
|
+
export function decodeRemainingLength(
|
|
153
|
+
bytes: Uint8Array,
|
|
154
|
+
offset: number,
|
|
155
|
+
): { value: number; bytesUsed: number } | null {
|
|
156
|
+
let multiplier = 1;
|
|
157
|
+
let value = 0;
|
|
158
|
+
for (let i = 0; i < 4; i++) {
|
|
159
|
+
const idx = offset + i;
|
|
160
|
+
if (idx >= bytes.length) return null;
|
|
161
|
+
const byte = bytes[idx]!;
|
|
162
|
+
value += (byte & 127) * multiplier;
|
|
163
|
+
if ((byte & 128) === 0) return { value, bytesUsed: i + 1 };
|
|
164
|
+
multiplier *= 128;
|
|
165
|
+
}
|
|
166
|
+
throw new Error("Remaining Length hỏng (quá 4 byte)");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function frame(type: number, flags: number, body: number[]): Uint8Array {
|
|
170
|
+
return Uint8Array.from([
|
|
171
|
+
(type << 4) | flags,
|
|
172
|
+
...encodeRemainingLength(body.length),
|
|
173
|
+
...body,
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ===== Gói gửi đi =====
|
|
178
|
+
|
|
179
|
+
export function encodeConnect(o: ConnectOptions): Uint8Array {
|
|
180
|
+
let flags = 0;
|
|
181
|
+
if (o.cleanSession) flags |= 0x02;
|
|
182
|
+
if (o.will) {
|
|
183
|
+
flags |= 0x04;
|
|
184
|
+
flags |= (o.will.qos & 0x03) << 3;
|
|
185
|
+
if (o.will.retain) flags |= 0x20;
|
|
186
|
+
}
|
|
187
|
+
if (o.password !== undefined) flags |= 0x40;
|
|
188
|
+
if (o.username !== undefined) flags |= 0x80;
|
|
189
|
+
|
|
190
|
+
const body = [
|
|
191
|
+
...encodeString("MQTT"),
|
|
192
|
+
0x04, // protocol level 4 = MQTT 3.1.1
|
|
193
|
+
flags,
|
|
194
|
+
(o.keepaliveS >> 8) & 0xff,
|
|
195
|
+
o.keepaliveS & 0xff,
|
|
196
|
+
...encodeString(o.clientId),
|
|
197
|
+
];
|
|
198
|
+
// Thứ tự payload là BẮT BUỘC: will topic → will message → username →
|
|
199
|
+
// password. Đảo là broker đọc lệch và đá kết nối mà không nói vì sao.
|
|
200
|
+
if (o.will) {
|
|
201
|
+
body.push(...encodeString(o.will.topic));
|
|
202
|
+
const payload = utf8Encode(o.will.payload);
|
|
203
|
+
body.push(payload.length >> 8, payload.length & 0xff, ...payload);
|
|
204
|
+
}
|
|
205
|
+
if (o.username !== undefined) body.push(...encodeString(o.username));
|
|
206
|
+
if (o.password !== undefined) body.push(...encodeString(o.password));
|
|
207
|
+
return frame(PACKET.CONNECT, 0, body);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function encodeSubscribe(
|
|
211
|
+
packetId: number,
|
|
212
|
+
filters: { topic: string; qos: 0 | 1 }[],
|
|
213
|
+
): Uint8Array {
|
|
214
|
+
const body = [packetId >> 8, packetId & 0xff];
|
|
215
|
+
for (const f of filters) {
|
|
216
|
+
body.push(...encodeString(f.topic), f.qos);
|
|
217
|
+
}
|
|
218
|
+
// Bit 1 của flags PHẢI bằng 1 với SUBSCRIBE (mục 3.8.1) — thiếu là broker
|
|
219
|
+
// đóng kết nối ngay, không CONNACK, không lời giải thích.
|
|
220
|
+
return frame(PACKET.SUBSCRIBE, 0x02, body);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function encodePublish(o: {
|
|
224
|
+
topic: string;
|
|
225
|
+
payload: string;
|
|
226
|
+
qos: 0 | 1;
|
|
227
|
+
retain: boolean;
|
|
228
|
+
packetId?: number;
|
|
229
|
+
}): Uint8Array {
|
|
230
|
+
let flags = 0;
|
|
231
|
+
if (o.retain) flags |= 0x01;
|
|
232
|
+
flags |= (o.qos & 0x03) << 1;
|
|
233
|
+
const body = [...encodeString(o.topic)];
|
|
234
|
+
if (o.qos > 0) {
|
|
235
|
+
if (o.packetId === undefined) throw new Error("PUBLISH QoS1 thiếu packetId");
|
|
236
|
+
body.push(o.packetId >> 8, o.packetId & 0xff);
|
|
237
|
+
}
|
|
238
|
+
body.push(...utf8Encode(o.payload));
|
|
239
|
+
return frame(PACKET.PUBLISH, flags, body);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function encodePuback(packetId: number): Uint8Array {
|
|
243
|
+
return frame(PACKET.PUBACK, 0, [packetId >> 8, packetId & 0xff]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function encodePingReq(): Uint8Array {
|
|
247
|
+
return frame(PACKET.PINGREQ, 0, []);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function encodeDisconnect(): Uint8Array {
|
|
251
|
+
return frame(PACKET.DISCONNECT, 0, []);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ===== Gói nhận về =====
|
|
255
|
+
|
|
256
|
+
export type MqttPacket =
|
|
257
|
+
| { type: "connack"; sessionPresent: boolean; returnCode: number }
|
|
258
|
+
| { type: "suback"; packetId: number; codes: number[] }
|
|
259
|
+
| {
|
|
260
|
+
type: "publish";
|
|
261
|
+
topic: string;
|
|
262
|
+
payload: string;
|
|
263
|
+
qos: 0 | 1;
|
|
264
|
+
retain: boolean;
|
|
265
|
+
packetId?: number;
|
|
266
|
+
}
|
|
267
|
+
| { type: "puback"; packetId: number }
|
|
268
|
+
| { type: "pingresp" }
|
|
269
|
+
| { type: "other"; packetType: number };
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Tách các gói hoàn chỉnh ra khỏi buffer tích luỹ.
|
|
273
|
+
*
|
|
274
|
+
* WebSocket KHÔNG bảo toàn ranh giới gói MQTT: một frame có thể chứa 3 gói, và
|
|
275
|
+
* một gói có thể nằm vắt qua 2 frame. Vì thế caller phải giữ `rest` lại và nối
|
|
276
|
+
* frame sau vào — đây đúng là chỗ mà bản tự viết cẩu thả sẽ chạy ngon trên
|
|
277
|
+
* mạng LAN rồi vỡ ngoài sân.
|
|
278
|
+
*/
|
|
279
|
+
export function decodePackets(buffer: Uint8Array): {
|
|
280
|
+
packets: MqttPacket[];
|
|
281
|
+
rest: Uint8Array;
|
|
282
|
+
} {
|
|
283
|
+
const packets: MqttPacket[] = [];
|
|
284
|
+
let offset = 0;
|
|
285
|
+
for (;;) {
|
|
286
|
+
if (offset >= buffer.length) break;
|
|
287
|
+
const header = buffer[offset]!;
|
|
288
|
+
const len = decodeRemainingLength(buffer, offset + 1);
|
|
289
|
+
if (!len) break; // varint còn dở → chờ frame sau
|
|
290
|
+
const start = offset + 1 + len.bytesUsed;
|
|
291
|
+
const end = start + len.value;
|
|
292
|
+
if (end > buffer.length) break; // thân gói còn dở
|
|
293
|
+
const type = header >> 4;
|
|
294
|
+
const flags = header & 0x0f;
|
|
295
|
+
const body = buffer.subarray(start, end);
|
|
296
|
+
packets.push(decodeBody(type, flags, body));
|
|
297
|
+
offset = end;
|
|
298
|
+
}
|
|
299
|
+
return { packets, rest: buffer.subarray(offset) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function readString(body: Uint8Array, offset: number): { value: string; next: number } {
|
|
303
|
+
const len = (body[offset]! << 8) | body[offset + 1]!;
|
|
304
|
+
const start = offset + 2;
|
|
305
|
+
return { value: utf8Decode(body.subarray(start, start + len)), next: start + len };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function decodeBody(type: number, flags: number, body: Uint8Array): MqttPacket {
|
|
309
|
+
switch (type) {
|
|
310
|
+
case PACKET.CONNACK:
|
|
311
|
+
return {
|
|
312
|
+
type: "connack",
|
|
313
|
+
sessionPresent: (body[0]! & 0x01) === 1,
|
|
314
|
+
returnCode: body[1] ?? 0,
|
|
315
|
+
};
|
|
316
|
+
case PACKET.SUBACK:
|
|
317
|
+
return {
|
|
318
|
+
type: "suback",
|
|
319
|
+
packetId: (body[0]! << 8) | body[1]!,
|
|
320
|
+
codes: Array.from(body.subarray(2)),
|
|
321
|
+
};
|
|
322
|
+
case PACKET.PUBLISH: {
|
|
323
|
+
const qos = ((flags >> 1) & 0x03) as 0 | 1;
|
|
324
|
+
const topic = readString(body, 0);
|
|
325
|
+
let next = topic.next;
|
|
326
|
+
let packetId: number | undefined;
|
|
327
|
+
if (qos > 0) {
|
|
328
|
+
packetId = (body[next]! << 8) | body[next + 1]!;
|
|
329
|
+
next += 2;
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
type: "publish",
|
|
333
|
+
topic: topic.value,
|
|
334
|
+
payload: utf8Decode(body.subarray(next)),
|
|
335
|
+
qos,
|
|
336
|
+
retain: (flags & 0x01) === 1,
|
|
337
|
+
...(packetId !== undefined ? { packetId } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
case PACKET.PUBACK:
|
|
341
|
+
return { type: "puback", packetId: (body[0]! << 8) | body[1]! };
|
|
342
|
+
case PACKET.PINGRESP:
|
|
343
|
+
return { type: "pingresp" };
|
|
344
|
+
default:
|
|
345
|
+
return { type: "other", packetType: type };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TournamentController — "chế độ giải đấu" cho điện thoại camera đặt tại sân.
|
|
3
|
+
*
|
|
4
|
+
* Vận hành arm máy MỘT lần khi setup giải: controller giữ đúng một phiên
|
|
5
|
+
* standby gắn sân (metadata.courtRef) đậu trong phòng LiveKit cả ngày ($0
|
|
6
|
+
* egress). Backend đậu phiên vào bảng courts (bindStandbySession) để REST
|
|
7
|
+
* match-start của đối tác tiêu thụ: trọng tài bấm start → server claim phiên +
|
|
8
|
+
* flip live + lệnh "start" qua data channel — không ai chạm điện thoại.
|
|
9
|
+
*
|
|
10
|
+
* Khi phiên kết thúc VÌ BẤT KỲ LÝ DO GÌ (match-end, standby_timeout 15 phút,
|
|
11
|
+
* watchdog, rớt mạng, app khởi động lại) controller tự re-arm phiên standby
|
|
12
|
+
* mới cho cùng sân sau vài giây — một state machine duy nhất xử lý trận nối
|
|
13
|
+
* trận, reboot và hồi phục lỗi.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { LivestreamEngine } from "../engine";
|
|
17
|
+
|
|
18
|
+
/** Góc nhìn tối thiểu lên engine — test truyền fake object thường. */
|
|
19
|
+
export interface TournamentEngineView {
|
|
20
|
+
getSnapshot(): { state: string; sessionId: string | null };
|
|
21
|
+
subscribe(listener: () => void): () => void;
|
|
22
|
+
prepare(): Promise<void>;
|
|
23
|
+
start(
|
|
24
|
+
input: {
|
|
25
|
+
externalSessionId: string;
|
|
26
|
+
title: string;
|
|
27
|
+
visibility?: "public" | "private";
|
|
28
|
+
consentVersion: string;
|
|
29
|
+
metadata?: Record<string, string>;
|
|
30
|
+
},
|
|
31
|
+
options?: { autoPublish?: boolean },
|
|
32
|
+
): Promise<void>;
|
|
33
|
+
stop(reason: "user" | "error"): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TournamentPersistedState {
|
|
37
|
+
courtRef: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Persist trạng thái armed (AsyncStorage/SQLite tuỳ app) — reboot tự re-arm. */
|
|
41
|
+
export interface TournamentStorage {
|
|
42
|
+
load(): Promise<TournamentPersistedState | null>;
|
|
43
|
+
save(state: TournamentPersistedState | null): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TournamentPhase =
|
|
47
|
+
| "disarmed"
|
|
48
|
+
| "arming"
|
|
49
|
+
| "standby"
|
|
50
|
+
| "live"
|
|
51
|
+
| "retry_wait";
|
|
52
|
+
|
|
53
|
+
export interface TournamentState {
|
|
54
|
+
armed: boolean;
|
|
55
|
+
courtRef: string | null;
|
|
56
|
+
phase: TournamentPhase;
|
|
57
|
+
lastError: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TournamentStandbyStart {
|
|
61
|
+
consentVersion: string;
|
|
62
|
+
/** Tiêu đề phiên chờ theo sân — mặc định "{courtRef} — chờ trận". */
|
|
63
|
+
titleFor?: (courtRef: string) => string;
|
|
64
|
+
visibility?: "public" | "private";
|
|
65
|
+
metadata?: Record<string, string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TournamentControllerOptions {
|
|
69
|
+
engine: TournamentEngineView | LivestreamEngine;
|
|
70
|
+
standbyStart: TournamentStandbyStart;
|
|
71
|
+
storage?: TournamentStorage;
|
|
72
|
+
onStateChange?: (state: TournamentState) => void;
|
|
73
|
+
/** Backoff re-arm khi lỗi liên tiếp (test truyền giá trị nhỏ). */
|
|
74
|
+
rearmBaseDelayMs?: number;
|
|
75
|
+
rearmMaxDelayMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Thời gian NHƯỜNG cho engine tự cứu phiên khi nó ở "error" mà còn sessionId.
|
|
78
|
+
* Phải dài hơn reconnectTimeoutMs của engine (mặc định 30s) để engine kịp
|
|
79
|
+
* hoặc hồi, hoặc tự cắt bằng network_timeout, trước khi controller can thiệp.
|
|
80
|
+
*/
|
|
81
|
+
recoveryGraceMs?: number;
|
|
82
|
+
now?: () => number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ACTIVE_ENGINE_STATES = new Set([
|
|
86
|
+
"preparing",
|
|
87
|
+
"connecting",
|
|
88
|
+
"standby",
|
|
89
|
+
"live",
|
|
90
|
+
"reconnecting",
|
|
91
|
+
"ending",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** Nhịp soi lại trong lúc nhường engine tự cứu — cố định, không backoff. */
|
|
95
|
+
const RECOVERY_CHECK_MS = 5_000;
|
|
96
|
+
/**
|
|
97
|
+
* Hạn chót mặc định phải LỚN HƠN cửa sổ reconnect của engine, nếu không
|
|
98
|
+
* controller dọn phiên trước khi engine kịp dùng hết lượt cứu. Engine để sàn 75s
|
|
99
|
+
* cho RTMP (chờ MediaMTX nhả publisher chết sau readTimeout 30s) → 90s.
|
|
100
|
+
*/
|
|
101
|
+
const DEFAULT_RECOVERY_GRACE_MS = 90_000;
|
|
102
|
+
|
|
103
|
+
export class TournamentController {
|
|
104
|
+
private readonly engine: TournamentEngineView;
|
|
105
|
+
private armed = false;
|
|
106
|
+
private courtRef: string | null = null;
|
|
107
|
+
private lastError: string | null = null;
|
|
108
|
+
private attemptInFlight = false;
|
|
109
|
+
private retryAttempt = 0;
|
|
110
|
+
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
111
|
+
/** Mốc hết nhường cho engine tự cứu phiên; null = đang không trong pha đó. */
|
|
112
|
+
private recoveryDeadline: number | null = null;
|
|
113
|
+
private unsubscribe: (() => void) | null = null;
|
|
114
|
+
private disposed = false;
|
|
115
|
+
/** Tăng mỗi lần arm/disarm — huỷ attempt cũ đang chạy dở. */
|
|
116
|
+
private armGeneration = 0;
|
|
117
|
+
|
|
118
|
+
constructor(private readonly options: TournamentControllerOptions) {
|
|
119
|
+
this.engine = options.engine as TournamentEngineView;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getState(): TournamentState {
|
|
123
|
+
const engineState = this.engine.getSnapshot().state;
|
|
124
|
+
let phase: TournamentPhase;
|
|
125
|
+
if (!this.armed) phase = "disarmed";
|
|
126
|
+
else if (engineState === "live") phase = "live";
|
|
127
|
+
else if (engineState === "standby") phase = "standby";
|
|
128
|
+
else if (this.retryTimer) phase = "retry_wait";
|
|
129
|
+
else phase = "arming";
|
|
130
|
+
return {
|
|
131
|
+
armed: this.armed,
|
|
132
|
+
courtRef: this.courtRef,
|
|
133
|
+
phase,
|
|
134
|
+
lastError: this.lastError,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Khôi phục sau khi app mở lại (reboot giữa giải) — gọi sau engine.initialize(). */
|
|
139
|
+
async restore() {
|
|
140
|
+
if (this.disposed || this.armed) return;
|
|
141
|
+
let saved: TournamentPersistedState | null = null;
|
|
142
|
+
try {
|
|
143
|
+
saved = (await this.options.storage?.load()) ?? null;
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (saved?.courtRef) await this.arm(saved.courtRef);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async arm(courtRef: string) {
|
|
151
|
+
if (this.disposed) return;
|
|
152
|
+
const normalized = courtRef.trim();
|
|
153
|
+
if (!normalized) throw new Error("courtRef không được để trống");
|
|
154
|
+
this.armed = true;
|
|
155
|
+
this.courtRef = normalized;
|
|
156
|
+
this.lastError = null;
|
|
157
|
+
this.retryAttempt = 0;
|
|
158
|
+
this.armGeneration += 1;
|
|
159
|
+
this.recoveryDeadline = null;
|
|
160
|
+
this.clearRetry();
|
|
161
|
+
try {
|
|
162
|
+
await this.options.storage?.save({ courtRef: normalized });
|
|
163
|
+
} catch {
|
|
164
|
+
// Không persist được thì reboot mất armed — vẫn arm cho phiên hiện tại
|
|
165
|
+
}
|
|
166
|
+
if (!this.unsubscribe) {
|
|
167
|
+
this.unsubscribe = this.engine.subscribe(() => this.handleEngineChange());
|
|
168
|
+
}
|
|
169
|
+
this.emit();
|
|
170
|
+
await this.ensureStandby();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Tắt chế độ giải đấu. Phiên standby đang chờ được nhả (stop); phiên đang
|
|
175
|
+
* LIVE là trận do đối tác điều khiển — không cắt, chỉ ngừng re-arm.
|
|
176
|
+
*/
|
|
177
|
+
async disarm() {
|
|
178
|
+
this.armed = false;
|
|
179
|
+
this.courtRef = null;
|
|
180
|
+
this.armGeneration += 1;
|
|
181
|
+
this.recoveryDeadline = null;
|
|
182
|
+
this.clearRetry();
|
|
183
|
+
try {
|
|
184
|
+
await this.options.storage?.save(null);
|
|
185
|
+
} catch {
|
|
186
|
+
// best effort
|
|
187
|
+
}
|
|
188
|
+
const snapshot = this.engine.getSnapshot();
|
|
189
|
+
if (snapshot.sessionId && snapshot.state === "standby") {
|
|
190
|
+
try {
|
|
191
|
+
await this.engine.stop("user");
|
|
192
|
+
} catch {
|
|
193
|
+
// Phiên chờ kẹt — server watchdog dọn
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.emit();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
dispose() {
|
|
200
|
+
this.disposed = true;
|
|
201
|
+
this.armed = false;
|
|
202
|
+
this.clearRetry();
|
|
203
|
+
this.unsubscribe?.();
|
|
204
|
+
this.unsubscribe = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private emit() {
|
|
208
|
+
this.options.onStateChange?.(this.getState());
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private clearRetry() {
|
|
212
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
213
|
+
this.retryTimer = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Engine còn đang tự cứu CHÍNH phiên này → nhường, đừng cắt để arm phiên mới. */
|
|
217
|
+
private isEngineRecovering(state: string, sessionId: string | null): boolean {
|
|
218
|
+
if (state !== "error" || !sessionId) return false;
|
|
219
|
+
const now = (this.options.now ?? Date.now)();
|
|
220
|
+
if (this.recoveryDeadline === null) {
|
|
221
|
+
this.recoveryDeadline =
|
|
222
|
+
now + (this.options.recoveryGraceMs ?? DEFAULT_RECOVERY_GRACE_MS);
|
|
223
|
+
}
|
|
224
|
+
return now < this.recoveryDeadline;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private scheduleRecoveryCheck() {
|
|
228
|
+
if (this.retryTimer || this.attemptInFlight || !this.armed) {
|
|
229
|
+
this.emit();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.retryTimer = setTimeout(() => {
|
|
233
|
+
this.retryTimer = null;
|
|
234
|
+
void this.ensureStandby();
|
|
235
|
+
}, RECOVERY_CHECK_MS);
|
|
236
|
+
this.emit();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private handleEngineChange() {
|
|
240
|
+
if (!this.armed || this.disposed) return;
|
|
241
|
+
const snapshot = this.engine.getSnapshot();
|
|
242
|
+
const state = snapshot.state;
|
|
243
|
+
// "error" mà VẪN CÒN sessionId = vòng redial + reconnect timer của engine
|
|
244
|
+
// đang cứu đúng phiên đó. Cắt nó ở đây là giết phiên còn cứu được: mất mạng
|
|
245
|
+
// 1–2 giây, engine chớp qua error, controller gọi stop("error") sau 2 giây
|
|
246
|
+
// rồi arm phiên mới → phiên chết với endReason "error" dù cửa sổ reconnect
|
|
247
|
+
// 30 giây chưa hề dùng tới. Đo trên prod 18/08/2026, cả iOS lẫn Android.
|
|
248
|
+
// Nhường tới hạn chót; quá hạn thì rơi xuống nhánh dưới dọn như cũ.
|
|
249
|
+
if (this.isEngineRecovering(state, snapshot.sessionId)) {
|
|
250
|
+
this.scheduleRecoveryCheck();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (state === "standby" || state === "live") this.recoveryDeadline = null;
|
|
254
|
+
if (state === "ended" || state === "idle" || state === "error") {
|
|
255
|
+
// Phiên vừa chết (match-end / timeout / watchdog / mất mạng) → đậu phiên mới
|
|
256
|
+
this.scheduleRearm();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
// "preview" không session mà vẫn armed = phải đang arm dở. Không có nhánh
|
|
260
|
+
// này thì race với AutoPrepare gây kẹt vĩnh viễn: phiên chết → timer re-arm
|
|
261
|
+
// 2s nổ đúng lúc app prepare() lại camera (state "preparing" → ensureStandby
|
|
262
|
+
// bail), prepare xong về "preview" thì không ai re-arm nữa.
|
|
263
|
+
if (state === "preview" && !snapshot.sessionId && !this.attemptInFlight) {
|
|
264
|
+
this.scheduleRearm();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (state === "standby" || state === "live") {
|
|
268
|
+
this.retryAttempt = 0;
|
|
269
|
+
this.lastError = null;
|
|
270
|
+
}
|
|
271
|
+
this.emit();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private scheduleRearm() {
|
|
275
|
+
if (this.retryTimer || this.attemptInFlight || !this.armed) {
|
|
276
|
+
this.emit();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const base = this.options.rearmBaseDelayMs ?? 2_000;
|
|
280
|
+
const max = this.options.rearmMaxDelayMs ?? 60_000;
|
|
281
|
+
const delay = Math.min(base * 2 ** this.retryAttempt, max);
|
|
282
|
+
this.retryTimer = setTimeout(() => {
|
|
283
|
+
this.retryTimer = null;
|
|
284
|
+
void this.ensureStandby();
|
|
285
|
+
}, delay);
|
|
286
|
+
this.emit();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private async ensureStandby() {
|
|
290
|
+
if (!this.armed || this.disposed || this.attemptInFlight) return;
|
|
291
|
+
const courtRef = this.courtRef;
|
|
292
|
+
if (!courtRef) return;
|
|
293
|
+
const generation = this.armGeneration;
|
|
294
|
+
const engineState = this.engine.getSnapshot().state;
|
|
295
|
+
// standby/live = phiên đang khoẻ, không làm gì. Các state chuyển tiếp
|
|
296
|
+
// (preparing/connecting/ending/reconnecting) thì HẸN LẠI thay vì bỏ cuộc —
|
|
297
|
+
// return im lặng ở đây là nguồn deadlock "arming" không lối thoát.
|
|
298
|
+
if (engineState === "standby" || engineState === "live") {
|
|
299
|
+
this.recoveryDeadline = null;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (ACTIVE_ENGINE_STATES.has(engineState)) {
|
|
303
|
+
this.scheduleRearm();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// Timer re-arm nổ trong lúc engine còn đang cứu phiên → soi lại, KHÔNG cắt.
|
|
307
|
+
if (this.isEngineRecovering(engineState, this.engine.getSnapshot().sessionId)) {
|
|
308
|
+
this.scheduleRecoveryCheck();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.attemptInFlight = true;
|
|
312
|
+
this.emit();
|
|
313
|
+
let failed = false;
|
|
314
|
+
try {
|
|
315
|
+
if (this.engine.getSnapshot().state === "error") {
|
|
316
|
+
try {
|
|
317
|
+
await this.engine.stop("error");
|
|
318
|
+
} catch {
|
|
319
|
+
// stop từ error thất bại — prepare bên dưới sẽ báo lỗi cụ thể
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (this.engine.getSnapshot().state !== "preview") {
|
|
323
|
+
await this.engine.prepare();
|
|
324
|
+
}
|
|
325
|
+
if (!this.armed || this.armGeneration !== generation) return;
|
|
326
|
+
const start = this.options.standbyStart;
|
|
327
|
+
const now = this.options.now ?? Date.now;
|
|
328
|
+
await this.engine.start(
|
|
329
|
+
{
|
|
330
|
+
externalSessionId: `tournament-${courtRef}-${now()}`,
|
|
331
|
+
title: start.titleFor?.(courtRef) ?? `${courtRef} — chờ trận`,
|
|
332
|
+
visibility: start.visibility ?? "public",
|
|
333
|
+
consentVersion: start.consentVersion,
|
|
334
|
+
metadata: { ...start.metadata, courtRef },
|
|
335
|
+
},
|
|
336
|
+
{ autoPublish: false },
|
|
337
|
+
);
|
|
338
|
+
this.retryAttempt = 0;
|
|
339
|
+
this.lastError = null;
|
|
340
|
+
} catch (error) {
|
|
341
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
342
|
+
this.retryAttempt += 1;
|
|
343
|
+
failed = true;
|
|
344
|
+
} finally {
|
|
345
|
+
this.attemptInFlight = false;
|
|
346
|
+
}
|
|
347
|
+
if (failed && this.armed && this.armGeneration === generation) {
|
|
348
|
+
this.scheduleRearm();
|
|
349
|
+
}
|
|
350
|
+
this.emit();
|
|
351
|
+
}
|
|
352
|
+
}
|