@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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createConvexCommandSubscription,
|
|
3
|
+
createConvexDeviceRealtime
|
|
4
|
+
} from "../chunk-6F3HN6US.js";
|
|
1
5
|
import {
|
|
2
6
|
ExpoSdkConfigurationError,
|
|
3
7
|
ExpoSdkTimeoutError,
|
|
@@ -7,7 +11,7 @@ import {
|
|
|
7
11
|
errorFromCode,
|
|
8
12
|
normalizeError,
|
|
9
13
|
useLivestreamHost
|
|
10
|
-
} from "../chunk-
|
|
14
|
+
} from "../chunk-XGP2MN5R.js";
|
|
11
15
|
import {
|
|
12
16
|
__esm,
|
|
13
17
|
__export,
|
|
@@ -31,7 +35,7 @@ function createConvexControlChannel(convexUrl) {
|
|
|
31
35
|
for (const item of value) {
|
|
32
36
|
if (item === null || typeof item !== "object") continue;
|
|
33
37
|
const record = item;
|
|
34
|
-
if (typeof record.id !== "string" || record.type !== "start_livestream" && record.type !== "stop_livestream" || typeof record.expiresAt !== "number") {
|
|
38
|
+
if (typeof record.id !== "string" || record.type !== "start_livestream" && record.type !== "stop_livestream" && record.type !== "publish_livestream" && record.type !== "unpublish_livestream" || typeof record.expiresAt !== "number") {
|
|
35
39
|
continue;
|
|
36
40
|
}
|
|
37
41
|
const payload = record.payload !== null && typeof record.payload === "object" && !Array.isArray(record.payload) ? record.payload : {};
|
|
@@ -40,7 +44,8 @@ function createConvexControlChannel(convexUrl) {
|
|
|
40
44
|
type: record.type,
|
|
41
45
|
payload: {
|
|
42
46
|
...typeof payload.title === "string" ? { title: payload.title } : {},
|
|
43
|
-
...payload.visibility === "public" || payload.visibility === "private" ? { visibility: payload.visibility } : {}
|
|
47
|
+
...payload.visibility === "public" || payload.visibility === "private" ? { visibility: payload.visibility } : {},
|
|
48
|
+
...typeof payload.courtRef === "string" && payload.courtRef.trim() !== "" ? { courtRef: payload.courtRef } : {}
|
|
44
49
|
},
|
|
45
50
|
expiresAt: record.expiresAt
|
|
46
51
|
});
|
|
@@ -82,10 +87,32 @@ var init_convex_control_channel = __esm({
|
|
|
82
87
|
}
|
|
83
88
|
});
|
|
84
89
|
|
|
90
|
+
// src/device-agent/beat-policy.ts
|
|
91
|
+
var BEAT_SLOW_MS = 6e4;
|
|
92
|
+
var BEAT_FAST_MS = 12e3;
|
|
93
|
+
var MQTT_HEALTHY_SILENCE_MS = 45e3;
|
|
94
|
+
var MQTT_HEALTHY_HOLD_MS = 12e4;
|
|
95
|
+
function mqttBeatHealthy(h, now) {
|
|
96
|
+
if (!h.connected || !h.subscribed) return false;
|
|
97
|
+
if (h.lastInboundAt <= 0 || now - h.lastInboundAt >= MQTT_HEALTHY_SILENCE_MS) return false;
|
|
98
|
+
if (h.healthySince <= 0 || now - h.healthySince < MQTT_HEALTHY_HOLD_MS) return false;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function nextBeatDelayMs(input) {
|
|
102
|
+
const hint = input.serverHintMs ?? BEAT_FAST_MS;
|
|
103
|
+
if (input.busy || !input.health) return BEAT_FAST_MS;
|
|
104
|
+
const local = mqttBeatHealthy(input.health, input.now) ? BEAT_SLOW_MS : BEAT_FAST_MS;
|
|
105
|
+
return Math.min(local, hint);
|
|
106
|
+
}
|
|
107
|
+
|
|
85
108
|
// src/device-agent/agent.ts
|
|
86
109
|
var DEFAULT_HEARTBEAT_INTERVAL_MS = 12e3;
|
|
110
|
+
var IDLE_HEARTBEAT_INTERVAL_MS = 12e3;
|
|
87
111
|
var REGISTER_RETRY_BASE_MS = 2e3;
|
|
88
112
|
var REGISTER_RETRY_MAX_MS = 6e4;
|
|
113
|
+
var METRICS_BASELINE_MS = 5 * 6e4;
|
|
114
|
+
var METRICS_BATTERY_LOW_PCT = 10;
|
|
115
|
+
var METRICS_BATTERY_DELTA_PCT = 5;
|
|
89
116
|
var METRIC_FIELDS = [
|
|
90
117
|
"ramFreeMb",
|
|
91
118
|
"ramTotalMb",
|
|
@@ -96,7 +123,9 @@ var METRIC_FIELDS = [
|
|
|
96
123
|
"netType",
|
|
97
124
|
"linkMbps",
|
|
98
125
|
"rssi",
|
|
99
|
-
"connQuality"
|
|
126
|
+
"connQuality",
|
|
127
|
+
"signalPct",
|
|
128
|
+
"localIp"
|
|
100
129
|
];
|
|
101
130
|
function defaultSubscribeAppState(listener) {
|
|
102
131
|
const { AppState } = __require("react-native");
|
|
@@ -121,17 +150,38 @@ function pickMetrics(sample) {
|
|
|
121
150
|
}
|
|
122
151
|
return Object.keys(metrics).length > 0 ? metrics : void 0;
|
|
123
152
|
}
|
|
153
|
+
function metricsCrossedThreshold(last, next) {
|
|
154
|
+
if (!last) return true;
|
|
155
|
+
const b0 = typeof last.batteryPct === "number" ? last.batteryPct : void 0;
|
|
156
|
+
const b1 = typeof next.batteryPct === "number" ? next.batteryPct : void 0;
|
|
157
|
+
if (b1 !== void 0) {
|
|
158
|
+
if (b1 < METRICS_BATTERY_LOW_PCT && (b0 === void 0 || b0 >= METRICS_BATTERY_LOW_PCT)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (b0 !== void 0 && Math.abs(b1 - b0) >= METRICS_BATTERY_DELTA_PCT) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (next.thermal !== last.thermal) return true;
|
|
166
|
+
if (next.netType !== last.netType) return true;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
124
169
|
var DeviceAgent = class {
|
|
125
170
|
options;
|
|
126
171
|
running = false;
|
|
127
172
|
registered = false;
|
|
128
173
|
foreground = true;
|
|
129
174
|
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
175
|
+
/** Nhịp server gợi ý ở beat gần nhất — undefined = server chưa nói gì → 12s. */
|
|
176
|
+
serverBeatHintMs;
|
|
130
177
|
timer = null;
|
|
131
178
|
beatInFlight = false;
|
|
132
179
|
registerAttempt = 0;
|
|
133
180
|
unsubscribeAppState = null;
|
|
134
181
|
heartbeatFailures = 0;
|
|
182
|
+
/** Metrics đã gửi lần gần nhất + mốc thời gian, để lọc "chỉ gửi khi đổi". */
|
|
183
|
+
lastSentMetrics;
|
|
184
|
+
lastMetricsSentAt = 0;
|
|
135
185
|
controlChannel = null;
|
|
136
186
|
controlSessionId = null;
|
|
137
187
|
controlUnsubscribers = [];
|
|
@@ -167,6 +217,28 @@ var DeviceAgent = class {
|
|
|
167
217
|
this.closeControlChannel();
|
|
168
218
|
}
|
|
169
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Đang có phiên hoặc đã mở kênh điều khiển = lệnh tới qua kênh, nhịp thường
|
|
222
|
+
* là đủ. Rảnh và chưa có kênh = đang chờ lệnh, đập nhanh cho kịp dashboard.
|
|
223
|
+
*/
|
|
224
|
+
nextBeatDelay() {
|
|
225
|
+
const base = this.controlChannel ? this.heartbeatIntervalMs : this.options.engine.getSnapshot().sessionId ? this.heartbeatIntervalMs : Math.min(IDLE_HEARTBEAT_INTERVAL_MS, this.heartbeatIntervalMs);
|
|
226
|
+
const health = (() => {
|
|
227
|
+
try {
|
|
228
|
+
return this.options.mqttHealth?.() ?? null;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
const adaptive = nextBeatDelayMs({
|
|
234
|
+
health,
|
|
235
|
+
now: Date.now(),
|
|
236
|
+
serverHintMs: this.serverBeatHintMs,
|
|
237
|
+
// Đang chạy lệnh / đang mở kênh điều khiển = có người chờ kết quả.
|
|
238
|
+
busy: this.executing || this.controlChannel !== null
|
|
239
|
+
});
|
|
240
|
+
return Math.max(base, adaptive);
|
|
241
|
+
}
|
|
170
242
|
schedule(delayMs) {
|
|
171
243
|
if (!this.running || !this.foreground) return;
|
|
172
244
|
if (this.timer) clearTimeout(this.timer);
|
|
@@ -183,7 +255,7 @@ var DeviceAgent = class {
|
|
|
183
255
|
if (!this.registered) return;
|
|
184
256
|
}
|
|
185
257
|
await this.heartbeat();
|
|
186
|
-
this.schedule(this.
|
|
258
|
+
this.schedule(this.nextBeatDelay());
|
|
187
259
|
} finally {
|
|
188
260
|
this.beatInFlight = false;
|
|
189
261
|
}
|
|
@@ -208,6 +280,21 @@ var DeviceAgent = class {
|
|
|
208
280
|
this.schedule(delay);
|
|
209
281
|
}
|
|
210
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* Lọc metrics cho heartbeat: chỉ trả (và ghi nhớ) khi vượt ngưỡng
|
|
285
|
+
* (metricsCrossedThreshold) hoặc đã quá METRICS_BASELINE_MS kể từ lần gửi.
|
|
286
|
+
* Trả undefined = nhịp này không kèm metrics.
|
|
287
|
+
*/
|
|
288
|
+
selectMetricsForHeartbeat(metrics) {
|
|
289
|
+
if (!metrics) return void 0;
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
const crossed = metricsCrossedThreshold(this.lastSentMetrics, metrics);
|
|
292
|
+
const baselineDue = now - this.lastMetricsSentAt >= METRICS_BASELINE_MS;
|
|
293
|
+
if (!crossed && !baselineDue) return void 0;
|
|
294
|
+
this.lastSentMetrics = metrics;
|
|
295
|
+
this.lastMetricsSentAt = now;
|
|
296
|
+
return metrics;
|
|
297
|
+
}
|
|
211
298
|
async heartbeat() {
|
|
212
299
|
const snapshot = this.options.engine.getSnapshot();
|
|
213
300
|
let metrics;
|
|
@@ -216,6 +303,13 @@ var DeviceAgent = class {
|
|
|
216
303
|
} catch {
|
|
217
304
|
metrics = void 0;
|
|
218
305
|
}
|
|
306
|
+
const metricsToSend = this.selectMetricsForHeartbeat(metrics);
|
|
307
|
+
let extras = {};
|
|
308
|
+
try {
|
|
309
|
+
extras = this.options.heartbeatExtras?.() ?? {};
|
|
310
|
+
} catch {
|
|
311
|
+
extras = {};
|
|
312
|
+
}
|
|
219
313
|
let result;
|
|
220
314
|
try {
|
|
221
315
|
result = await this.options.transport.heartbeat({
|
|
@@ -223,7 +317,12 @@ var DeviceAgent = class {
|
|
|
223
317
|
agentState: snapshot.state,
|
|
224
318
|
currentSessionId: snapshot.sessionId,
|
|
225
319
|
...this.options.deviceInfo?.appVersion ? { appVersion: this.options.deviceInfo.appVersion } : {},
|
|
226
|
-
...
|
|
320
|
+
...metricsToSend ? { metrics: metricsToSend } : {},
|
|
321
|
+
...extras.recentExits && extras.recentExits.length > 0 ? { recentExits: extras.recentExits } : {},
|
|
322
|
+
...extras.powerState !== void 0 ? { powerState: extras.powerState } : {},
|
|
323
|
+
// Chỉ gửi khi TRUE. Server đọc doc tỉ số mỗi nhịp có cờ này, nên gửi
|
|
324
|
+
// `false` cũng vô hại nhưng phí một field trên mọi beat của mọi máy.
|
|
325
|
+
...extras.wantScore ? { wantScore: true } : {}
|
|
227
326
|
});
|
|
228
327
|
} catch {
|
|
229
328
|
this.heartbeatFailures += 1;
|
|
@@ -234,6 +333,7 @@ var DeviceAgent = class {
|
|
|
234
333
|
return;
|
|
235
334
|
}
|
|
236
335
|
this.heartbeatFailures = 0;
|
|
336
|
+
this.serverBeatHintMs = typeof result.beatIntervalMs === "number" && result.beatIntervalMs > 0 ? result.beatIntervalMs : void 0;
|
|
237
337
|
if (result.controlSession && !this.controlChannel) {
|
|
238
338
|
this.openControlChannel(result.controlSession);
|
|
239
339
|
}
|
|
@@ -317,6 +417,12 @@ var DeviceAgent = class {
|
|
|
317
417
|
sessionId ?? void 0
|
|
318
418
|
);
|
|
319
419
|
if (sessionId) this.options.onRemoteSessionStarted?.(sessionId);
|
|
420
|
+
} else if (command.type === "publish_livestream") {
|
|
421
|
+
await this.runPublish();
|
|
422
|
+
await this.safeAck(channel, grant, command.id, "completed");
|
|
423
|
+
} else if (command.type === "unpublish_livestream") {
|
|
424
|
+
await this.runUnpublish();
|
|
425
|
+
await this.safeAck(channel, grant, command.id, "completed");
|
|
320
426
|
} else {
|
|
321
427
|
await this.runStopLivestream();
|
|
322
428
|
await this.safeAck(channel, grant, command.id, "completed");
|
|
@@ -341,13 +447,19 @@ var DeviceAgent = class {
|
|
|
341
447
|
await engine.prepare();
|
|
342
448
|
}
|
|
343
449
|
const remote = this.options.remoteStart;
|
|
450
|
+
const courtRef = command.payload.courtRef?.trim();
|
|
344
451
|
await engine.start(
|
|
345
452
|
{
|
|
346
453
|
externalSessionId: `remote-${command.id}`,
|
|
347
454
|
title: command.payload.title?.trim() || remote.defaultTitle || "Livestream",
|
|
348
455
|
visibility: command.payload.visibility ?? remote.visibility ?? "public",
|
|
349
456
|
consentVersion: remote.consentVersion,
|
|
350
|
-
metadata: {
|
|
457
|
+
metadata: {
|
|
458
|
+
...remote.metadata,
|
|
459
|
+
deviceCommandId: command.id,
|
|
460
|
+
// courtRef trong metadata → server đậu phiên standby vào sân
|
|
461
|
+
...courtRef ? { courtRef } : {}
|
|
462
|
+
},
|
|
351
463
|
standby: true
|
|
352
464
|
},
|
|
353
465
|
{ autoPublish: false }
|
|
@@ -359,6 +471,36 @@ var DeviceAgent = class {
|
|
|
359
471
|
await engine.stop("user");
|
|
360
472
|
}
|
|
361
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* standby → live theo lệnh dashboard. startPublishing() NUỐT lỗi (recordError
|
|
476
|
+
* vào snapshot thay vì throw) nên phải kiểm state sau khi gọi — không kiểm là
|
|
477
|
+
* lệnh được ack "completed" dù thiết bị chưa hề phát.
|
|
478
|
+
*/
|
|
479
|
+
async runPublish() {
|
|
480
|
+
const engine = this.options.engine;
|
|
481
|
+
const state = engine.getSnapshot().state;
|
|
482
|
+
if (state === "live") return;
|
|
483
|
+
if (state !== "standby") {
|
|
484
|
+
throw new Error(`Thi\u1EBFt b\u1ECB kh\xF4ng \u1EDF ch\u1EBF \u0111\u1ED9 ch\u1EDD (tr\u1EA1ng th\xE1i ${state})`);
|
|
485
|
+
}
|
|
486
|
+
await engine.startPublishing();
|
|
487
|
+
if (engine.getSnapshot().state !== "live") {
|
|
488
|
+
const message = engine.getSnapshot().error?.message;
|
|
489
|
+
throw new Error(message ? `Kh\xF4ng ph\xE1t \u0111\u01B0\u1EE3c: ${message}` : "Kh\xF4ng ph\xE1t \u0111\u01B0\u1EE3c");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async runUnpublish() {
|
|
493
|
+
const engine = this.options.engine;
|
|
494
|
+
const state = engine.getSnapshot().state;
|
|
495
|
+
if (state === "standby") return;
|
|
496
|
+
if (state !== "live") {
|
|
497
|
+
throw new Error(`Thi\u1EBFt b\u1ECB kh\xF4ng \u0111ang ph\xE1t (tr\u1EA1ng th\xE1i ${state})`);
|
|
498
|
+
}
|
|
499
|
+
await engine.stopPublishing();
|
|
500
|
+
if (engine.getSnapshot().state !== "standby") {
|
|
501
|
+
throw new Error("Kh\xF4ng d\u1EEBng ph\xE1t \u0111\u01B0\u1EE3c");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
362
504
|
async safeAck(channel, grant, commandId, status, errorMessage, resultSessionId) {
|
|
363
505
|
try {
|
|
364
506
|
await channel.ack({
|
|
@@ -429,6 +571,7 @@ function createHttpDeviceAgentTransport(options) {
|
|
|
429
571
|
if (!isRecord(data) || typeof data.deviceId !== "string" || typeof data.heartbeatIntervalMs !== "number" || !Number.isFinite(data.heartbeatIntervalMs)) {
|
|
430
572
|
throw new InvalidResponseError();
|
|
431
573
|
}
|
|
574
|
+
options.onData?.("register", data);
|
|
432
575
|
return {
|
|
433
576
|
deviceId: data.deviceId,
|
|
434
577
|
heartbeatIntervalMs: data.heartbeatIntervalMs
|
|
@@ -437,8 +580,12 @@ function createHttpDeviceAgentTransport(options) {
|
|
|
437
580
|
async heartbeat(input) {
|
|
438
581
|
const data = await post(paths.heartbeat, input);
|
|
439
582
|
if (!isRecord(data)) throw new InvalidResponseError();
|
|
583
|
+
options.onData?.("heartbeat", data);
|
|
584
|
+
const beatIntervalMs = typeof data.beatIntervalMs === "number" && Number.isFinite(data.beatIntervalMs) ? { beatIntervalMs: data.beatIntervalMs } : {};
|
|
440
585
|
const grant = data.controlSession;
|
|
441
|
-
if (grant === null || grant === void 0)
|
|
586
|
+
if (grant === null || grant === void 0) {
|
|
587
|
+
return { controlSession: null, ...beatIntervalMs };
|
|
588
|
+
}
|
|
442
589
|
if (!isRecord(grant) || typeof grant.id !== "string" || typeof grant.token !== "string" || typeof grant.convexUrl !== "string" || grant.convexUrl === "") {
|
|
443
590
|
throw new InvalidResponseError();
|
|
444
591
|
}
|
|
@@ -447,7 +594,8 @@ function createHttpDeviceAgentTransport(options) {
|
|
|
447
594
|
id: grant.id,
|
|
448
595
|
token: grant.token,
|
|
449
596
|
convexUrl: grant.convexUrl
|
|
450
|
-
}
|
|
597
|
+
},
|
|
598
|
+
...beatIntervalMs
|
|
451
599
|
};
|
|
452
600
|
}
|
|
453
601
|
};
|
|
@@ -465,9 +613,311 @@ function isRecord(value) {
|
|
|
465
613
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
466
614
|
}
|
|
467
615
|
|
|
616
|
+
// src/device-agent/tournament.ts
|
|
617
|
+
var ACTIVE_ENGINE_STATES = /* @__PURE__ */ new Set([
|
|
618
|
+
"preparing",
|
|
619
|
+
"connecting",
|
|
620
|
+
"standby",
|
|
621
|
+
"live",
|
|
622
|
+
"reconnecting",
|
|
623
|
+
"ending"
|
|
624
|
+
]);
|
|
625
|
+
var RECOVERY_CHECK_MS = 5e3;
|
|
626
|
+
var DEFAULT_RECOVERY_GRACE_MS = 9e4;
|
|
627
|
+
var TournamentController = class {
|
|
628
|
+
constructor(options) {
|
|
629
|
+
this.options = options;
|
|
630
|
+
this.engine = options.engine;
|
|
631
|
+
}
|
|
632
|
+
engine;
|
|
633
|
+
armed = false;
|
|
634
|
+
courtRef = null;
|
|
635
|
+
lastError = null;
|
|
636
|
+
attemptInFlight = false;
|
|
637
|
+
retryAttempt = 0;
|
|
638
|
+
retryTimer = null;
|
|
639
|
+
/** Mốc hết nhường cho engine tự cứu phiên; null = đang không trong pha đó. */
|
|
640
|
+
recoveryDeadline = null;
|
|
641
|
+
unsubscribe = null;
|
|
642
|
+
disposed = false;
|
|
643
|
+
/** Tăng mỗi lần arm/disarm — huỷ attempt cũ đang chạy dở. */
|
|
644
|
+
armGeneration = 0;
|
|
645
|
+
getState() {
|
|
646
|
+
const engineState = this.engine.getSnapshot().state;
|
|
647
|
+
let phase;
|
|
648
|
+
if (!this.armed) phase = "disarmed";
|
|
649
|
+
else if (engineState === "live") phase = "live";
|
|
650
|
+
else if (engineState === "standby") phase = "standby";
|
|
651
|
+
else if (this.retryTimer) phase = "retry_wait";
|
|
652
|
+
else phase = "arming";
|
|
653
|
+
return {
|
|
654
|
+
armed: this.armed,
|
|
655
|
+
courtRef: this.courtRef,
|
|
656
|
+
phase,
|
|
657
|
+
lastError: this.lastError
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** Khôi phục sau khi app mở lại (reboot giữa giải) — gọi sau engine.initialize(). */
|
|
661
|
+
async restore() {
|
|
662
|
+
if (this.disposed || this.armed) return;
|
|
663
|
+
let saved = null;
|
|
664
|
+
try {
|
|
665
|
+
saved = await this.options.storage?.load() ?? null;
|
|
666
|
+
} catch {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (saved?.courtRef) await this.arm(saved.courtRef);
|
|
670
|
+
}
|
|
671
|
+
async arm(courtRef) {
|
|
672
|
+
if (this.disposed) return;
|
|
673
|
+
const normalized = courtRef.trim();
|
|
674
|
+
if (!normalized) throw new Error("courtRef kh\xF4ng \u0111\u01B0\u1EE3c \u0111\u1EC3 tr\u1ED1ng");
|
|
675
|
+
this.armed = true;
|
|
676
|
+
this.courtRef = normalized;
|
|
677
|
+
this.lastError = null;
|
|
678
|
+
this.retryAttempt = 0;
|
|
679
|
+
this.armGeneration += 1;
|
|
680
|
+
this.recoveryDeadline = null;
|
|
681
|
+
this.clearRetry();
|
|
682
|
+
try {
|
|
683
|
+
await this.options.storage?.save({ courtRef: normalized });
|
|
684
|
+
} catch {
|
|
685
|
+
}
|
|
686
|
+
if (!this.unsubscribe) {
|
|
687
|
+
this.unsubscribe = this.engine.subscribe(() => this.handleEngineChange());
|
|
688
|
+
}
|
|
689
|
+
this.emit();
|
|
690
|
+
await this.ensureStandby();
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Tắt chế độ giải đấu. Phiên standby đang chờ được nhả (stop); phiên đang
|
|
694
|
+
* LIVE là trận do đối tác điều khiển — không cắt, chỉ ngừng re-arm.
|
|
695
|
+
*/
|
|
696
|
+
async disarm() {
|
|
697
|
+
this.armed = false;
|
|
698
|
+
this.courtRef = null;
|
|
699
|
+
this.armGeneration += 1;
|
|
700
|
+
this.recoveryDeadline = null;
|
|
701
|
+
this.clearRetry();
|
|
702
|
+
try {
|
|
703
|
+
await this.options.storage?.save(null);
|
|
704
|
+
} catch {
|
|
705
|
+
}
|
|
706
|
+
const snapshot = this.engine.getSnapshot();
|
|
707
|
+
if (snapshot.sessionId && snapshot.state === "standby") {
|
|
708
|
+
try {
|
|
709
|
+
await this.engine.stop("user");
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
this.emit();
|
|
714
|
+
}
|
|
715
|
+
dispose() {
|
|
716
|
+
this.disposed = true;
|
|
717
|
+
this.armed = false;
|
|
718
|
+
this.clearRetry();
|
|
719
|
+
this.unsubscribe?.();
|
|
720
|
+
this.unsubscribe = null;
|
|
721
|
+
}
|
|
722
|
+
emit() {
|
|
723
|
+
this.options.onStateChange?.(this.getState());
|
|
724
|
+
}
|
|
725
|
+
clearRetry() {
|
|
726
|
+
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
727
|
+
this.retryTimer = null;
|
|
728
|
+
}
|
|
729
|
+
/** Engine còn đang tự cứu CHÍNH phiên này → nhường, đừng cắt để arm phiên mới. */
|
|
730
|
+
isEngineRecovering(state, sessionId) {
|
|
731
|
+
if (state !== "error" || !sessionId) return false;
|
|
732
|
+
const now = (this.options.now ?? Date.now)();
|
|
733
|
+
if (this.recoveryDeadline === null) {
|
|
734
|
+
this.recoveryDeadline = now + (this.options.recoveryGraceMs ?? DEFAULT_RECOVERY_GRACE_MS);
|
|
735
|
+
}
|
|
736
|
+
return now < this.recoveryDeadline;
|
|
737
|
+
}
|
|
738
|
+
scheduleRecoveryCheck() {
|
|
739
|
+
if (this.retryTimer || this.attemptInFlight || !this.armed) {
|
|
740
|
+
this.emit();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
this.retryTimer = setTimeout(() => {
|
|
744
|
+
this.retryTimer = null;
|
|
745
|
+
void this.ensureStandby();
|
|
746
|
+
}, RECOVERY_CHECK_MS);
|
|
747
|
+
this.emit();
|
|
748
|
+
}
|
|
749
|
+
handleEngineChange() {
|
|
750
|
+
if (!this.armed || this.disposed) return;
|
|
751
|
+
const snapshot = this.engine.getSnapshot();
|
|
752
|
+
const state = snapshot.state;
|
|
753
|
+
if (this.isEngineRecovering(state, snapshot.sessionId)) {
|
|
754
|
+
this.scheduleRecoveryCheck();
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (state === "standby" || state === "live") this.recoveryDeadline = null;
|
|
758
|
+
if (state === "ended" || state === "idle" || state === "error") {
|
|
759
|
+
this.scheduleRearm();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (state === "preview" && !snapshot.sessionId && !this.attemptInFlight) {
|
|
763
|
+
this.scheduleRearm();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (state === "standby" || state === "live") {
|
|
767
|
+
this.retryAttempt = 0;
|
|
768
|
+
this.lastError = null;
|
|
769
|
+
}
|
|
770
|
+
this.emit();
|
|
771
|
+
}
|
|
772
|
+
scheduleRearm() {
|
|
773
|
+
if (this.retryTimer || this.attemptInFlight || !this.armed) {
|
|
774
|
+
this.emit();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const base = this.options.rearmBaseDelayMs ?? 2e3;
|
|
778
|
+
const max = this.options.rearmMaxDelayMs ?? 6e4;
|
|
779
|
+
const delay = Math.min(base * 2 ** this.retryAttempt, max);
|
|
780
|
+
this.retryTimer = setTimeout(() => {
|
|
781
|
+
this.retryTimer = null;
|
|
782
|
+
void this.ensureStandby();
|
|
783
|
+
}, delay);
|
|
784
|
+
this.emit();
|
|
785
|
+
}
|
|
786
|
+
async ensureStandby() {
|
|
787
|
+
if (!this.armed || this.disposed || this.attemptInFlight) return;
|
|
788
|
+
const courtRef = this.courtRef;
|
|
789
|
+
if (!courtRef) return;
|
|
790
|
+
const generation = this.armGeneration;
|
|
791
|
+
const engineState = this.engine.getSnapshot().state;
|
|
792
|
+
if (engineState === "standby" || engineState === "live") {
|
|
793
|
+
this.recoveryDeadline = null;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
if (ACTIVE_ENGINE_STATES.has(engineState)) {
|
|
797
|
+
this.scheduleRearm();
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (this.isEngineRecovering(engineState, this.engine.getSnapshot().sessionId)) {
|
|
801
|
+
this.scheduleRecoveryCheck();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
this.attemptInFlight = true;
|
|
805
|
+
this.emit();
|
|
806
|
+
let failed = false;
|
|
807
|
+
try {
|
|
808
|
+
if (this.engine.getSnapshot().state === "error") {
|
|
809
|
+
try {
|
|
810
|
+
await this.engine.stop("error");
|
|
811
|
+
} catch {
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
if (this.engine.getSnapshot().state !== "preview") {
|
|
815
|
+
await this.engine.prepare();
|
|
816
|
+
}
|
|
817
|
+
if (!this.armed || this.armGeneration !== generation) return;
|
|
818
|
+
const start = this.options.standbyStart;
|
|
819
|
+
const now = this.options.now ?? Date.now;
|
|
820
|
+
await this.engine.start(
|
|
821
|
+
{
|
|
822
|
+
externalSessionId: `tournament-${courtRef}-${now()}`,
|
|
823
|
+
title: start.titleFor?.(courtRef) ?? `${courtRef} \u2014 ch\u1EDD tr\u1EADn`,
|
|
824
|
+
visibility: start.visibility ?? "public",
|
|
825
|
+
consentVersion: start.consentVersion,
|
|
826
|
+
metadata: { ...start.metadata, courtRef }
|
|
827
|
+
},
|
|
828
|
+
{ autoPublish: false }
|
|
829
|
+
);
|
|
830
|
+
this.retryAttempt = 0;
|
|
831
|
+
this.lastError = null;
|
|
832
|
+
} catch (error) {
|
|
833
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
834
|
+
this.retryAttempt += 1;
|
|
835
|
+
failed = true;
|
|
836
|
+
} finally {
|
|
837
|
+
this.attemptInFlight = false;
|
|
838
|
+
}
|
|
839
|
+
if (failed && this.armed && this.armGeneration === generation) {
|
|
840
|
+
this.scheduleRearm();
|
|
841
|
+
}
|
|
842
|
+
this.emit();
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
468
846
|
// src/device-agent/index.ts
|
|
469
847
|
init_convex_control_channel();
|
|
470
848
|
|
|
849
|
+
// src/device-agent/convex-beat-transport.ts
|
|
850
|
+
var WS_BEAT_RETRY_MS = 6e4;
|
|
851
|
+
var WS_BEAT_TIMEOUT_MS = 1e4;
|
|
852
|
+
function withConvexBeat(base, realtime, options) {
|
|
853
|
+
const now = options.now ?? Date.now;
|
|
854
|
+
const timeoutMs = options.timeoutMs ?? WS_BEAT_TIMEOUT_MS;
|
|
855
|
+
let wsBrokenUntil = 0;
|
|
856
|
+
return {
|
|
857
|
+
register(input) {
|
|
858
|
+
return base.register(input);
|
|
859
|
+
},
|
|
860
|
+
async heartbeat(input) {
|
|
861
|
+
const deviceToken = options.getDeviceToken();
|
|
862
|
+
if (!deviceToken || now() < wsBrokenUntil) {
|
|
863
|
+
return base.heartbeat(input);
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const data = await withTimeout(
|
|
867
|
+
realtime.beat({
|
|
868
|
+
deviceToken,
|
|
869
|
+
// Server tra máy theo token — bootstrapInput (installationId…) chỉ
|
|
870
|
+
// đường HTTP cần. Chỉ gửi đúng phần mutation beat nhận.
|
|
871
|
+
agentState: input.agentState,
|
|
872
|
+
currentSessionId: input.currentSessionId,
|
|
873
|
+
...input.appVersion !== void 0 ? { appVersion: input.appVersion } : {},
|
|
874
|
+
...input.metrics !== void 0 ? { metrics: input.metrics } : {},
|
|
875
|
+
...input.recentExits !== void 0 && input.recentExits.length > 0 ? { recentExits: input.recentExits } : {},
|
|
876
|
+
...input.powerState !== void 0 ? { powerState: input.powerState } : {}
|
|
877
|
+
}),
|
|
878
|
+
timeoutMs
|
|
879
|
+
);
|
|
880
|
+
options.onData?.("heartbeat", data);
|
|
881
|
+
return {
|
|
882
|
+
controlSession: parseControlSession(data.controlSession),
|
|
883
|
+
...typeof data.beatIntervalMs === "number" && Number.isFinite(data.beatIntervalMs) ? { beatIntervalMs: data.beatIntervalMs } : {}
|
|
884
|
+
};
|
|
885
|
+
} catch {
|
|
886
|
+
wsBrokenUntil = now() + WS_BEAT_RETRY_MS;
|
|
887
|
+
return base.heartbeat(input);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function parseControlSession(grant) {
|
|
893
|
+
if (grant === null || grant === void 0) return null;
|
|
894
|
+
if (grant === null || typeof grant !== "object" || typeof grant.id !== "string" || typeof grant.token !== "string" || typeof grant.convexUrl !== "string" || grant.convexUrl === "") {
|
|
895
|
+
throw new Error("Invalid controlSession in beat response");
|
|
896
|
+
}
|
|
897
|
+
const record = grant;
|
|
898
|
+
return {
|
|
899
|
+
id: record.id,
|
|
900
|
+
token: record.token,
|
|
901
|
+
convexUrl: record.convexUrl
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function withTimeout(promise, ms) {
|
|
905
|
+
return new Promise((resolve, reject) => {
|
|
906
|
+
const timer = setTimeout(() => reject(new Error("beat timeout")), ms);
|
|
907
|
+
timer.unref?.();
|
|
908
|
+
promise.then(
|
|
909
|
+
(value) => {
|
|
910
|
+
clearTimeout(timer);
|
|
911
|
+
resolve(value);
|
|
912
|
+
},
|
|
913
|
+
(error) => {
|
|
914
|
+
clearTimeout(timer);
|
|
915
|
+
reject(error);
|
|
916
|
+
}
|
|
917
|
+
);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
471
921
|
// src/device-agent/component.tsx
|
|
472
922
|
import { useEffect, useRef } from "react";
|
|
473
923
|
function PickleballDeviceAgent(props) {
|
|
@@ -483,16 +933,495 @@ function PickleballDeviceAgent(props) {
|
|
|
483
933
|
remoteStart: propsRef.current.remoteStart,
|
|
484
934
|
deviceInfo: propsRef.current.deviceInfo,
|
|
485
935
|
createControlChannel: propsRef.current.createControlChannel,
|
|
486
|
-
onRemoteSessionStarted: (sessionId) => propsRef.current.onRemoteSessionStarted?.(sessionId)
|
|
936
|
+
onRemoteSessionStarted: (sessionId) => propsRef.current.onRemoteSessionStarted?.(sessionId),
|
|
937
|
+
heartbeatExtras: () => propsRef.current.heartbeatExtras?.(),
|
|
938
|
+
mqttHealth: () => propsRef.current.mqttHealth?.() ?? null
|
|
487
939
|
});
|
|
488
940
|
agent.start();
|
|
489
941
|
return () => agent.stop();
|
|
490
942
|
}, [host]);
|
|
491
943
|
return null;
|
|
492
944
|
}
|
|
945
|
+
|
|
946
|
+
// src/device-agent/mqtt-codec.ts
|
|
947
|
+
var PACKET = {
|
|
948
|
+
CONNECT: 1,
|
|
949
|
+
CONNACK: 2,
|
|
950
|
+
PUBLISH: 3,
|
|
951
|
+
PUBACK: 4,
|
|
952
|
+
SUBSCRIBE: 8,
|
|
953
|
+
SUBACK: 9,
|
|
954
|
+
PINGREQ: 12,
|
|
955
|
+
PINGRESP: 13,
|
|
956
|
+
DISCONNECT: 14
|
|
957
|
+
};
|
|
958
|
+
var CONNACK_REASON = {
|
|
959
|
+
0: "ch\u1EA5p nh\u1EADn",
|
|
960
|
+
1: "sai phi\xEAn b\u1EA3n giao th\u1EE9c",
|
|
961
|
+
2: "clientId b\u1ECB t\u1EEB ch\u1ED1i",
|
|
962
|
+
3: "broker kh\xF4ng s\u1EB5n s\xE0ng",
|
|
963
|
+
4: "sai username/password",
|
|
964
|
+
5: "kh\xF4ng \u0111\u01B0\u1EE3c ph\xE9p (auth hook t\u1EEB ch\u1ED1i)"
|
|
965
|
+
};
|
|
966
|
+
function utf8Encode(s) {
|
|
967
|
+
const out = [];
|
|
968
|
+
for (let i = 0; i < s.length; i++) {
|
|
969
|
+
let code = s.charCodeAt(i);
|
|
970
|
+
if (code >= 55296 && code <= 56319 && i + 1 < s.length) {
|
|
971
|
+
const next = s.charCodeAt(i + 1);
|
|
972
|
+
if (next >= 56320 && next <= 57343) {
|
|
973
|
+
code = (code - 55296 << 10) + (next - 56320) + 65536;
|
|
974
|
+
i++;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (code < 128) out.push(code);
|
|
978
|
+
else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);
|
|
979
|
+
else if (code < 65536)
|
|
980
|
+
out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);
|
|
981
|
+
else
|
|
982
|
+
out.push(
|
|
983
|
+
240 | code >> 18,
|
|
984
|
+
128 | code >> 12 & 63,
|
|
985
|
+
128 | code >> 6 & 63,
|
|
986
|
+
128 | code & 63
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
return Uint8Array.from(out);
|
|
990
|
+
}
|
|
991
|
+
function utf8Decode(bytes) {
|
|
992
|
+
let out = "";
|
|
993
|
+
for (let i = 0; i < bytes.length; ) {
|
|
994
|
+
const b = bytes[i];
|
|
995
|
+
let code;
|
|
996
|
+
if (b < 128) {
|
|
997
|
+
code = b;
|
|
998
|
+
i += 1;
|
|
999
|
+
} else if (b < 224) {
|
|
1000
|
+
code = (b & 31) << 6 | bytes[i + 1] & 63;
|
|
1001
|
+
i += 2;
|
|
1002
|
+
} else if (b < 240) {
|
|
1003
|
+
code = (b & 15) << 12 | (bytes[i + 1] & 63) << 6 | bytes[i + 2] & 63;
|
|
1004
|
+
i += 3;
|
|
1005
|
+
} else {
|
|
1006
|
+
code = (b & 7) << 18 | (bytes[i + 1] & 63) << 12 | (bytes[i + 2] & 63) << 6 | bytes[i + 3] & 63;
|
|
1007
|
+
i += 4;
|
|
1008
|
+
}
|
|
1009
|
+
if (code > 65535) {
|
|
1010
|
+
code -= 65536;
|
|
1011
|
+
out += String.fromCharCode(55296 + (code >> 10), 56320 + (code & 1023));
|
|
1012
|
+
} else {
|
|
1013
|
+
out += String.fromCharCode(code);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
function encodeString(s) {
|
|
1019
|
+
const bytes = utf8Encode(s);
|
|
1020
|
+
if (bytes.length > 65535) throw new Error("Chu\u1ED7i MQTT v\u01B0\u1EE3t 65535 byte");
|
|
1021
|
+
return [bytes.length >> 8, bytes.length & 255, ...bytes];
|
|
1022
|
+
}
|
|
1023
|
+
function encodeRemainingLength(n) {
|
|
1024
|
+
if (n < 0 || n > 268435455) throw new Error(`\u0110\u1ED9 d\xE0i MQTT kh\xF4ng h\u1EE3p l\u1EC7: ${n}`);
|
|
1025
|
+
const out = [];
|
|
1026
|
+
let value = n;
|
|
1027
|
+
do {
|
|
1028
|
+
let byte = value % 128;
|
|
1029
|
+
value = Math.floor(value / 128);
|
|
1030
|
+
if (value > 0) byte |= 128;
|
|
1031
|
+
out.push(byte);
|
|
1032
|
+
} while (value > 0);
|
|
1033
|
+
return out;
|
|
1034
|
+
}
|
|
1035
|
+
function decodeRemainingLength(bytes, offset) {
|
|
1036
|
+
let multiplier = 1;
|
|
1037
|
+
let value = 0;
|
|
1038
|
+
for (let i = 0; i < 4; i++) {
|
|
1039
|
+
const idx = offset + i;
|
|
1040
|
+
if (idx >= bytes.length) return null;
|
|
1041
|
+
const byte = bytes[idx];
|
|
1042
|
+
value += (byte & 127) * multiplier;
|
|
1043
|
+
if ((byte & 128) === 0) return { value, bytesUsed: i + 1 };
|
|
1044
|
+
multiplier *= 128;
|
|
1045
|
+
}
|
|
1046
|
+
throw new Error("Remaining Length h\u1ECFng (qu\xE1 4 byte)");
|
|
1047
|
+
}
|
|
1048
|
+
function frame(type, flags, body) {
|
|
1049
|
+
return Uint8Array.from([
|
|
1050
|
+
type << 4 | flags,
|
|
1051
|
+
...encodeRemainingLength(body.length),
|
|
1052
|
+
...body
|
|
1053
|
+
]);
|
|
1054
|
+
}
|
|
1055
|
+
function encodeConnect(o) {
|
|
1056
|
+
let flags = 0;
|
|
1057
|
+
if (o.cleanSession) flags |= 2;
|
|
1058
|
+
if (o.will) {
|
|
1059
|
+
flags |= 4;
|
|
1060
|
+
flags |= (o.will.qos & 3) << 3;
|
|
1061
|
+
if (o.will.retain) flags |= 32;
|
|
1062
|
+
}
|
|
1063
|
+
if (o.password !== void 0) flags |= 64;
|
|
1064
|
+
if (o.username !== void 0) flags |= 128;
|
|
1065
|
+
const body = [
|
|
1066
|
+
...encodeString("MQTT"),
|
|
1067
|
+
4,
|
|
1068
|
+
// protocol level 4 = MQTT 3.1.1
|
|
1069
|
+
flags,
|
|
1070
|
+
o.keepaliveS >> 8 & 255,
|
|
1071
|
+
o.keepaliveS & 255,
|
|
1072
|
+
...encodeString(o.clientId)
|
|
1073
|
+
];
|
|
1074
|
+
if (o.will) {
|
|
1075
|
+
body.push(...encodeString(o.will.topic));
|
|
1076
|
+
const payload = utf8Encode(o.will.payload);
|
|
1077
|
+
body.push(payload.length >> 8, payload.length & 255, ...payload);
|
|
1078
|
+
}
|
|
1079
|
+
if (o.username !== void 0) body.push(...encodeString(o.username));
|
|
1080
|
+
if (o.password !== void 0) body.push(...encodeString(o.password));
|
|
1081
|
+
return frame(PACKET.CONNECT, 0, body);
|
|
1082
|
+
}
|
|
1083
|
+
function encodeSubscribe(packetId, filters) {
|
|
1084
|
+
const body = [packetId >> 8, packetId & 255];
|
|
1085
|
+
for (const f of filters) {
|
|
1086
|
+
body.push(...encodeString(f.topic), f.qos);
|
|
1087
|
+
}
|
|
1088
|
+
return frame(PACKET.SUBSCRIBE, 2, body);
|
|
1089
|
+
}
|
|
1090
|
+
function encodePublish(o) {
|
|
1091
|
+
let flags = 0;
|
|
1092
|
+
if (o.retain) flags |= 1;
|
|
1093
|
+
flags |= (o.qos & 3) << 1;
|
|
1094
|
+
const body = [...encodeString(o.topic)];
|
|
1095
|
+
if (o.qos > 0) {
|
|
1096
|
+
if (o.packetId === void 0) throw new Error("PUBLISH QoS1 thi\u1EBFu packetId");
|
|
1097
|
+
body.push(o.packetId >> 8, o.packetId & 255);
|
|
1098
|
+
}
|
|
1099
|
+
body.push(...utf8Encode(o.payload));
|
|
1100
|
+
return frame(PACKET.PUBLISH, flags, body);
|
|
1101
|
+
}
|
|
1102
|
+
function encodePuback(packetId) {
|
|
1103
|
+
return frame(PACKET.PUBACK, 0, [packetId >> 8, packetId & 255]);
|
|
1104
|
+
}
|
|
1105
|
+
function encodePingReq() {
|
|
1106
|
+
return frame(PACKET.PINGREQ, 0, []);
|
|
1107
|
+
}
|
|
1108
|
+
function encodeDisconnect() {
|
|
1109
|
+
return frame(PACKET.DISCONNECT, 0, []);
|
|
1110
|
+
}
|
|
1111
|
+
function decodePackets(buffer) {
|
|
1112
|
+
const packets = [];
|
|
1113
|
+
let offset = 0;
|
|
1114
|
+
for (; ; ) {
|
|
1115
|
+
if (offset >= buffer.length) break;
|
|
1116
|
+
const header = buffer[offset];
|
|
1117
|
+
const len = decodeRemainingLength(buffer, offset + 1);
|
|
1118
|
+
if (!len) break;
|
|
1119
|
+
const start = offset + 1 + len.bytesUsed;
|
|
1120
|
+
const end = start + len.value;
|
|
1121
|
+
if (end > buffer.length) break;
|
|
1122
|
+
const type = header >> 4;
|
|
1123
|
+
const flags = header & 15;
|
|
1124
|
+
const body = buffer.subarray(start, end);
|
|
1125
|
+
packets.push(decodeBody(type, flags, body));
|
|
1126
|
+
offset = end;
|
|
1127
|
+
}
|
|
1128
|
+
return { packets, rest: buffer.subarray(offset) };
|
|
1129
|
+
}
|
|
1130
|
+
function readString(body, offset) {
|
|
1131
|
+
const len = body[offset] << 8 | body[offset + 1];
|
|
1132
|
+
const start = offset + 2;
|
|
1133
|
+
return { value: utf8Decode(body.subarray(start, start + len)), next: start + len };
|
|
1134
|
+
}
|
|
1135
|
+
function decodeBody(type, flags, body) {
|
|
1136
|
+
switch (type) {
|
|
1137
|
+
case PACKET.CONNACK:
|
|
1138
|
+
return {
|
|
1139
|
+
type: "connack",
|
|
1140
|
+
sessionPresent: (body[0] & 1) === 1,
|
|
1141
|
+
returnCode: body[1] ?? 0
|
|
1142
|
+
};
|
|
1143
|
+
case PACKET.SUBACK:
|
|
1144
|
+
return {
|
|
1145
|
+
type: "suback",
|
|
1146
|
+
packetId: body[0] << 8 | body[1],
|
|
1147
|
+
codes: Array.from(body.subarray(2))
|
|
1148
|
+
};
|
|
1149
|
+
case PACKET.PUBLISH: {
|
|
1150
|
+
const qos = flags >> 1 & 3;
|
|
1151
|
+
const topic = readString(body, 0);
|
|
1152
|
+
let next = topic.next;
|
|
1153
|
+
let packetId;
|
|
1154
|
+
if (qos > 0) {
|
|
1155
|
+
packetId = body[next] << 8 | body[next + 1];
|
|
1156
|
+
next += 2;
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
type: "publish",
|
|
1160
|
+
topic: topic.value,
|
|
1161
|
+
payload: utf8Decode(body.subarray(next)),
|
|
1162
|
+
qos,
|
|
1163
|
+
retain: (flags & 1) === 1,
|
|
1164
|
+
...packetId !== void 0 ? { packetId } : {}
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
case PACKET.PUBACK:
|
|
1168
|
+
return { type: "puback", packetId: body[0] << 8 | body[1] };
|
|
1169
|
+
case PACKET.PINGRESP:
|
|
1170
|
+
return { type: "pingresp" };
|
|
1171
|
+
default:
|
|
1172
|
+
return { type: "other", packetType: type };
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// src/device-agent/mqtt-channel.ts
|
|
1177
|
+
var AUTH_RETRY_MS = 5 * 6e4;
|
|
1178
|
+
function createMqttChannel(o, deps = {}) {
|
|
1179
|
+
const createSocket = deps.createSocket ?? ((url, protocols) => new WebSocket(url, protocols));
|
|
1180
|
+
const setTimer = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
1181
|
+
const clearTimer = deps.clearTimeout ?? ((h) => clearTimeout(h));
|
|
1182
|
+
const now = deps.now ?? (() => Date.now());
|
|
1183
|
+
const random = deps.random ?? Math.random;
|
|
1184
|
+
let socket = null;
|
|
1185
|
+
let buffer = new Uint8Array(0);
|
|
1186
|
+
let connected = false;
|
|
1187
|
+
let stopped = true;
|
|
1188
|
+
let attempt = 0;
|
|
1189
|
+
let packetId = 0;
|
|
1190
|
+
let reconnectTimer = null;
|
|
1191
|
+
let pingTimer = null;
|
|
1192
|
+
let lastInboundAt = 0;
|
|
1193
|
+
let subscribed = false;
|
|
1194
|
+
let healthySince = 0;
|
|
1195
|
+
const nextPacketId = () => {
|
|
1196
|
+
packetId = packetId % 65535 + 1;
|
|
1197
|
+
return packetId;
|
|
1198
|
+
};
|
|
1199
|
+
const setConnected = (value) => {
|
|
1200
|
+
if (connected === value) return;
|
|
1201
|
+
connected = value;
|
|
1202
|
+
o.onConnectionChange?.(value);
|
|
1203
|
+
};
|
|
1204
|
+
const clearTimers = () => {
|
|
1205
|
+
if (reconnectTimer !== null) {
|
|
1206
|
+
clearTimer(reconnectTimer);
|
|
1207
|
+
reconnectTimer = null;
|
|
1208
|
+
}
|
|
1209
|
+
if (pingTimer !== null) {
|
|
1210
|
+
clearTimer(pingTimer);
|
|
1211
|
+
pingTimer = null;
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
const send = (bytes) => {
|
|
1215
|
+
if (!socket) return false;
|
|
1216
|
+
try {
|
|
1217
|
+
socket.send(bytes);
|
|
1218
|
+
return true;
|
|
1219
|
+
} catch {
|
|
1220
|
+
teardown(true);
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
const scheduleReconnect = (delayMs) => {
|
|
1225
|
+
if (stopped || reconnectTimer !== null) return;
|
|
1226
|
+
const base = delayMs ?? o.reconnectDelaysMs[Math.min(attempt, o.reconnectDelaysMs.length - 1)] ?? 3e4;
|
|
1227
|
+
const jitter = base === 0 ? 0 : base * 0.2 * (random() * 2 - 1);
|
|
1228
|
+
attempt += 1;
|
|
1229
|
+
reconnectTimer = setTimer(() => {
|
|
1230
|
+
reconnectTimer = null;
|
|
1231
|
+
open();
|
|
1232
|
+
}, Math.max(0, Math.round(base + jitter)));
|
|
1233
|
+
};
|
|
1234
|
+
const teardown = (reconnect, delayMs) => {
|
|
1235
|
+
clearTimers();
|
|
1236
|
+
setConnected(false);
|
|
1237
|
+
subscribed = false;
|
|
1238
|
+
healthySince = 0;
|
|
1239
|
+
buffer = new Uint8Array(0);
|
|
1240
|
+
const dead = socket;
|
|
1241
|
+
socket = null;
|
|
1242
|
+
if (dead) {
|
|
1243
|
+
dead.onopen = null;
|
|
1244
|
+
dead.onmessage = null;
|
|
1245
|
+
dead.onerror = null;
|
|
1246
|
+
dead.onclose = null;
|
|
1247
|
+
try {
|
|
1248
|
+
dead.close();
|
|
1249
|
+
} catch {
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (reconnect) scheduleReconnect(delayMs);
|
|
1253
|
+
};
|
|
1254
|
+
const armPing = () => {
|
|
1255
|
+
if (pingTimer !== null) clearTimer(pingTimer);
|
|
1256
|
+
pingTimer = setTimer(() => {
|
|
1257
|
+
pingTimer = null;
|
|
1258
|
+
if (!socket) return;
|
|
1259
|
+
if (now() - lastInboundAt > o.keepaliveS * 1500) {
|
|
1260
|
+
teardown(true);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
send(encodePingReq());
|
|
1264
|
+
armPing();
|
|
1265
|
+
}, Math.max(1e3, Math.round(o.keepaliveS * 750)));
|
|
1266
|
+
};
|
|
1267
|
+
const handlePacket = (p) => {
|
|
1268
|
+
switch (p.type) {
|
|
1269
|
+
case "connack": {
|
|
1270
|
+
if (p.returnCode !== 0) {
|
|
1271
|
+
const reason = CONNACK_REASON[p.returnCode] ?? `m\xE3 ${p.returnCode}`;
|
|
1272
|
+
o.onAuthRejected?.(reason);
|
|
1273
|
+
teardown(true, p.returnCode === 4 || p.returnCode === 5 ? AUTH_RETRY_MS : void 0);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
attempt = 0;
|
|
1277
|
+
setConnected(true);
|
|
1278
|
+
send(
|
|
1279
|
+
encodePublish({
|
|
1280
|
+
topic: o.topics.status,
|
|
1281
|
+
payload: o.statusPayload(true),
|
|
1282
|
+
qos: 1,
|
|
1283
|
+
retain: true,
|
|
1284
|
+
packetId: nextPacketId()
|
|
1285
|
+
})
|
|
1286
|
+
);
|
|
1287
|
+
send(
|
|
1288
|
+
encodeSubscribe(nextPacketId(), [
|
|
1289
|
+
{ topic: o.topics.cmd, qos: 1 },
|
|
1290
|
+
{ topic: o.topics.cfg, qos: 1 },
|
|
1291
|
+
// Tỉ số trận đang ghi (retained) — reconnect là nhận ngay bản mới nhất.
|
|
1292
|
+
{ topic: o.topics.score, qos: 1 }
|
|
1293
|
+
])
|
|
1294
|
+
);
|
|
1295
|
+
armPing();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
case "suback": {
|
|
1299
|
+
if (p.codes.every((c) => c < 128)) {
|
|
1300
|
+
subscribed = true;
|
|
1301
|
+
if (healthySince === 0) healthySince = now();
|
|
1302
|
+
}
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
case "publish": {
|
|
1306
|
+
if (p.qos === 1 && p.packetId !== void 0) send(encodePuback(p.packetId));
|
|
1307
|
+
o.onMessage?.(p.topic, p.payload);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
default:
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
};
|
|
1314
|
+
const open = () => {
|
|
1315
|
+
if (stopped || socket) return;
|
|
1316
|
+
let s;
|
|
1317
|
+
try {
|
|
1318
|
+
s = createSocket(o.url, ["mqtt"]);
|
|
1319
|
+
} catch {
|
|
1320
|
+
scheduleReconnect();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
socket = s;
|
|
1324
|
+
s.binaryType = "arraybuffer";
|
|
1325
|
+
lastInboundAt = now();
|
|
1326
|
+
s.onopen = () => {
|
|
1327
|
+
if (socket !== s) return;
|
|
1328
|
+
send(
|
|
1329
|
+
encodeConnect({
|
|
1330
|
+
clientId: o.deviceId,
|
|
1331
|
+
username: o.deviceId,
|
|
1332
|
+
password: o.deviceToken,
|
|
1333
|
+
keepaliveS: o.keepaliveS,
|
|
1334
|
+
cleanSession: true,
|
|
1335
|
+
will: {
|
|
1336
|
+
topic: o.topics.status,
|
|
1337
|
+
payload: o.statusPayload(false),
|
|
1338
|
+
qos: 1,
|
|
1339
|
+
retain: true
|
|
1340
|
+
}
|
|
1341
|
+
})
|
|
1342
|
+
);
|
|
1343
|
+
};
|
|
1344
|
+
s.onmessage = (event) => {
|
|
1345
|
+
if (socket !== s) return;
|
|
1346
|
+
const data = event.data;
|
|
1347
|
+
let chunk = null;
|
|
1348
|
+
if (data instanceof ArrayBuffer) {
|
|
1349
|
+
chunk = new Uint8Array(data);
|
|
1350
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
1351
|
+
chunk = Uint8Array.from(data);
|
|
1352
|
+
}
|
|
1353
|
+
if (!chunk) return;
|
|
1354
|
+
lastInboundAt = now();
|
|
1355
|
+
const merged = new Uint8Array(buffer.length + chunk.length);
|
|
1356
|
+
merged.set(buffer, 0);
|
|
1357
|
+
merged.set(chunk, buffer.length);
|
|
1358
|
+
const { packets, rest } = decodePackets(merged);
|
|
1359
|
+
buffer = Uint8Array.from(rest);
|
|
1360
|
+
for (const p of packets) handlePacket(p);
|
|
1361
|
+
};
|
|
1362
|
+
s.onerror = () => {
|
|
1363
|
+
if (socket !== s) return;
|
|
1364
|
+
teardown(true);
|
|
1365
|
+
};
|
|
1366
|
+
s.onclose = () => {
|
|
1367
|
+
if (socket !== s) return;
|
|
1368
|
+
teardown(true);
|
|
1369
|
+
};
|
|
1370
|
+
};
|
|
1371
|
+
return {
|
|
1372
|
+
start() {
|
|
1373
|
+
if (!stopped) return;
|
|
1374
|
+
stopped = false;
|
|
1375
|
+
attempt = 0;
|
|
1376
|
+
open();
|
|
1377
|
+
},
|
|
1378
|
+
stop() {
|
|
1379
|
+
if (stopped) return;
|
|
1380
|
+
stopped = true;
|
|
1381
|
+
if (socket && connected) {
|
|
1382
|
+
send(
|
|
1383
|
+
encodePublish({
|
|
1384
|
+
topic: o.topics.status,
|
|
1385
|
+
payload: o.statusPayload(false),
|
|
1386
|
+
qos: 1,
|
|
1387
|
+
retain: true,
|
|
1388
|
+
packetId: nextPacketId()
|
|
1389
|
+
})
|
|
1390
|
+
);
|
|
1391
|
+
send(encodeDisconnect());
|
|
1392
|
+
}
|
|
1393
|
+
teardown(false);
|
|
1394
|
+
},
|
|
1395
|
+
isConnected: () => connected,
|
|
1396
|
+
health: () => ({ connected, subscribed, lastInboundAt, healthySince }),
|
|
1397
|
+
publish(topic, payload, opts) {
|
|
1398
|
+
if (!connected) return false;
|
|
1399
|
+
const qos = opts?.qos ?? 0;
|
|
1400
|
+
return send(
|
|
1401
|
+
encodePublish({
|
|
1402
|
+
topic,
|
|
1403
|
+
payload,
|
|
1404
|
+
qos,
|
|
1405
|
+
retain: opts?.retain ?? false,
|
|
1406
|
+
...qos === 1 ? { packetId: nextPacketId() } : {}
|
|
1407
|
+
})
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
493
1412
|
export {
|
|
1413
|
+
BEAT_FAST_MS,
|
|
1414
|
+
BEAT_SLOW_MS,
|
|
494
1415
|
DeviceAgent,
|
|
495
1416
|
PickleballDeviceAgent,
|
|
1417
|
+
TournamentController,
|
|
1418
|
+
createConvexCommandSubscription,
|
|
496
1419
|
createConvexControlChannel,
|
|
497
|
-
|
|
1420
|
+
createConvexDeviceRealtime,
|
|
1421
|
+
createHttpDeviceAgentTransport,
|
|
1422
|
+
createMqttChannel,
|
|
1423
|
+
metricsCrossedThreshold,
|
|
1424
|
+
mqttBeatHealthy,
|
|
1425
|
+
nextBeatDelayMs,
|
|
1426
|
+
withConvexBeat
|
|
498
1427
|
};
|