@pickleball/expo-sdk 0.1.1 → 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 +65 -0
- package/README.md +163 -40
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +47 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +47 -0
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-2JHULJZ7.js +265 -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-7OX2DSKI.cjs +33 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-GXQYMPHE.cjs +9 -0
- package/dist/chunk-IUIRVKJI.cjs +2290 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/chunk-XGP2MN5R.js +2290 -0
- package/dist/chunk-XL4CISVQ.js +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- 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 +1427 -0
- package/dist/device-agent/index.d.cts +493 -0
- package/dist/device-agent/index.d.ts +493 -0
- package/dist/device-agent/index.js +1427 -0
- package/dist/engine-BSvSdCSE.d.cts +741 -0
- package/dist/engine-BSvSdCSE.d.ts +741 -0
- package/dist/index.cjs +426 -6174
- package/dist/index.d.cts +299 -1400
- package/dist/index.d.ts +299 -1400
- package/dist/index.js +413 -6063
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +52 -71
- package/dist/plugin.d.cts +28 -1
- package/dist/plugin.d.ts +28 -1
- package/dist/plugin.js +26 -23
- 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/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -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 +4 -32
- package/dist/version.js +2 -1
- package/package.json +95 -15
- package/src/advanced.ts +49 -0
- 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 +696 -0
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +60 -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 +88 -0
- package/src/device-agent/http-transport.ts +164 -0
- package/src/device-agent/index.ts +26 -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 +601 -20
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +21 -183
- package/src/index.ts +117 -14
- package/src/local-video-quality.ts +43 -0
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +283 -77
- package/src/plugin.ts +54 -20
- package/src/provider.tsx +129 -22
- 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 +348 -0
- 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/chunk-VHLUJQG5.js +0 -17
- package/src/livekit-room-adapter.ts +0 -353
- package/src/livestream-screen.tsx +0 -980
|
@@ -0,0 +1,1427 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createConvexCommandSubscription,
|
|
3
|
+
createConvexDeviceRealtime
|
|
4
|
+
} from "../chunk-6F3HN6US.js";
|
|
5
|
+
import {
|
|
6
|
+
ExpoSdkConfigurationError,
|
|
7
|
+
ExpoSdkTimeoutError,
|
|
8
|
+
InvalidResponseError,
|
|
9
|
+
NetworkUnavailableError,
|
|
10
|
+
PickleballExpoError,
|
|
11
|
+
errorFromCode,
|
|
12
|
+
normalizeError,
|
|
13
|
+
useLivestreamHost
|
|
14
|
+
} from "../chunk-XGP2MN5R.js";
|
|
15
|
+
import {
|
|
16
|
+
__esm,
|
|
17
|
+
__export,
|
|
18
|
+
__require,
|
|
19
|
+
__toCommonJS
|
|
20
|
+
} from "../chunk-KFQGP6VL.js";
|
|
21
|
+
|
|
22
|
+
// src/device-agent/convex-control-channel.ts
|
|
23
|
+
var convex_control_channel_exports = {};
|
|
24
|
+
__export(convex_control_channel_exports, {
|
|
25
|
+
createConvexControlChannel: () => createConvexControlChannel
|
|
26
|
+
});
|
|
27
|
+
import { ConvexClient } from "convex/browser";
|
|
28
|
+
import { anyApi } from "convex/server";
|
|
29
|
+
function createConvexControlChannel(convexUrl) {
|
|
30
|
+
const client = new ConvexClient(convexUrl);
|
|
31
|
+
const deviceControl = anyApi.deviceControl;
|
|
32
|
+
function toCommands(value) {
|
|
33
|
+
if (!Array.isArray(value)) return [];
|
|
34
|
+
const commands = [];
|
|
35
|
+
for (const item of value) {
|
|
36
|
+
if (item === null || typeof item !== "object") continue;
|
|
37
|
+
const record = item;
|
|
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") {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const payload = record.payload !== null && typeof record.payload === "object" && !Array.isArray(record.payload) ? record.payload : {};
|
|
42
|
+
commands.push({
|
|
43
|
+
id: record.id,
|
|
44
|
+
type: record.type,
|
|
45
|
+
payload: {
|
|
46
|
+
...typeof payload.title === "string" ? { title: payload.title } : {},
|
|
47
|
+
...payload.visibility === "public" || payload.visibility === "private" ? { visibility: payload.visibility } : {},
|
|
48
|
+
...typeof payload.courtRef === "string" && payload.courtRef.trim() !== "" ? { courtRef: payload.courtRef } : {}
|
|
49
|
+
},
|
|
50
|
+
expiresAt: record.expiresAt
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return commands;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
async attach(controlSessionId, token) {
|
|
57
|
+
await client.mutation(deviceControl.deviceAttach, { controlSessionId, token });
|
|
58
|
+
},
|
|
59
|
+
onCommands(controlSessionId, token, callback) {
|
|
60
|
+
return client.onUpdate(
|
|
61
|
+
deviceControl.pendingCommands,
|
|
62
|
+
{ controlSessionId, token },
|
|
63
|
+
(value) => callback(toCommands(value))
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
onState(controlSessionId, token, callback) {
|
|
67
|
+
return client.onUpdate(
|
|
68
|
+
deviceControl.controlSessionState,
|
|
69
|
+
{ controlSessionId, token },
|
|
70
|
+
(value) => {
|
|
71
|
+
const status = value !== null && typeof value === "object" && typeof value.status === "string" ? value.status : "ended";
|
|
72
|
+
callback({ status });
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
},
|
|
76
|
+
async ack(input) {
|
|
77
|
+
await client.mutation(deviceControl.ackCommand, { ...input });
|
|
78
|
+
},
|
|
79
|
+
close() {
|
|
80
|
+
void client.close();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
var init_convex_control_channel = __esm({
|
|
85
|
+
"src/device-agent/convex-control-channel.ts"() {
|
|
86
|
+
"use strict";
|
|
87
|
+
}
|
|
88
|
+
});
|
|
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
|
+
|
|
108
|
+
// src/device-agent/agent.ts
|
|
109
|
+
var DEFAULT_HEARTBEAT_INTERVAL_MS = 12e3;
|
|
110
|
+
var IDLE_HEARTBEAT_INTERVAL_MS = 12e3;
|
|
111
|
+
var REGISTER_RETRY_BASE_MS = 2e3;
|
|
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;
|
|
116
|
+
var METRIC_FIELDS = [
|
|
117
|
+
"ramFreeMb",
|
|
118
|
+
"ramTotalMb",
|
|
119
|
+
"storageFreeGb",
|
|
120
|
+
"batteryPct",
|
|
121
|
+
"batteryTempC",
|
|
122
|
+
"thermal",
|
|
123
|
+
"netType",
|
|
124
|
+
"linkMbps",
|
|
125
|
+
"rssi",
|
|
126
|
+
"connQuality",
|
|
127
|
+
"signalPct",
|
|
128
|
+
"localIp"
|
|
129
|
+
];
|
|
130
|
+
function defaultSubscribeAppState(listener) {
|
|
131
|
+
const { AppState } = __require("react-native");
|
|
132
|
+
const subscription = AppState.addEventListener("change", (state) => {
|
|
133
|
+
listener(
|
|
134
|
+
state === "active" ? "active" : state === "background" ? "background" : "inactive"
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
return () => subscription.remove();
|
|
138
|
+
}
|
|
139
|
+
function defaultCreateControlChannel(convexUrl) {
|
|
140
|
+
const { createConvexControlChannel: createConvexControlChannel2 } = (init_convex_control_channel(), __toCommonJS(convex_control_channel_exports));
|
|
141
|
+
return createConvexControlChannel2(convexUrl);
|
|
142
|
+
}
|
|
143
|
+
function pickMetrics(sample) {
|
|
144
|
+
if (!sample) return void 0;
|
|
145
|
+
const metrics = {};
|
|
146
|
+
for (const field of METRIC_FIELDS) {
|
|
147
|
+
const value = sample[field];
|
|
148
|
+
if (typeof value === "number" && Number.isFinite(value)) metrics[field] = value;
|
|
149
|
+
else if (typeof value === "string" && value.trim() !== "") metrics[field] = value;
|
|
150
|
+
}
|
|
151
|
+
return Object.keys(metrics).length > 0 ? metrics : void 0;
|
|
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
|
+
}
|
|
169
|
+
var DeviceAgent = class {
|
|
170
|
+
options;
|
|
171
|
+
running = false;
|
|
172
|
+
registered = false;
|
|
173
|
+
foreground = true;
|
|
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;
|
|
177
|
+
timer = null;
|
|
178
|
+
beatInFlight = false;
|
|
179
|
+
registerAttempt = 0;
|
|
180
|
+
unsubscribeAppState = null;
|
|
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;
|
|
185
|
+
controlChannel = null;
|
|
186
|
+
controlSessionId = null;
|
|
187
|
+
controlUnsubscribers = [];
|
|
188
|
+
executing = false;
|
|
189
|
+
handledCommandIds = /* @__PURE__ */ new Set();
|
|
190
|
+
constructor(options) {
|
|
191
|
+
this.options = options;
|
|
192
|
+
}
|
|
193
|
+
start() {
|
|
194
|
+
if (this.running) return;
|
|
195
|
+
this.running = true;
|
|
196
|
+
this.unsubscribeAppState = (this.options.subscribeAppState ?? defaultSubscribeAppState)((state) => this.handleAppState(state));
|
|
197
|
+
this.schedule(0);
|
|
198
|
+
}
|
|
199
|
+
stop() {
|
|
200
|
+
this.running = false;
|
|
201
|
+
if (this.timer) clearTimeout(this.timer);
|
|
202
|
+
this.timer = null;
|
|
203
|
+
this.unsubscribeAppState?.();
|
|
204
|
+
this.unsubscribeAppState = null;
|
|
205
|
+
this.closeControlChannel();
|
|
206
|
+
}
|
|
207
|
+
handleAppState(state) {
|
|
208
|
+
const wasForeground = this.foreground;
|
|
209
|
+
this.foreground = state === "active";
|
|
210
|
+
if (!this.running) return;
|
|
211
|
+
if (this.foreground && !wasForeground) {
|
|
212
|
+
this.schedule(0);
|
|
213
|
+
}
|
|
214
|
+
if (!this.foreground) {
|
|
215
|
+
if (this.timer) clearTimeout(this.timer);
|
|
216
|
+
this.timer = null;
|
|
217
|
+
this.closeControlChannel();
|
|
218
|
+
}
|
|
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
|
+
}
|
|
242
|
+
schedule(delayMs) {
|
|
243
|
+
if (!this.running || !this.foreground) return;
|
|
244
|
+
if (this.timer) clearTimeout(this.timer);
|
|
245
|
+
this.timer = setTimeout(() => {
|
|
246
|
+
void this.beat();
|
|
247
|
+
}, delayMs);
|
|
248
|
+
}
|
|
249
|
+
async beat() {
|
|
250
|
+
if (!this.running || !this.foreground || this.beatInFlight) return;
|
|
251
|
+
this.beatInFlight = true;
|
|
252
|
+
try {
|
|
253
|
+
if (!this.registered) {
|
|
254
|
+
await this.register();
|
|
255
|
+
if (!this.registered) return;
|
|
256
|
+
}
|
|
257
|
+
await this.heartbeat();
|
|
258
|
+
this.schedule(this.nextBeatDelay());
|
|
259
|
+
} finally {
|
|
260
|
+
this.beatInFlight = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async register() {
|
|
264
|
+
try {
|
|
265
|
+
const result = await this.options.transport.register({
|
|
266
|
+
...this.options.bootstrapInput,
|
|
267
|
+
...this.options.deviceInfo
|
|
268
|
+
});
|
|
269
|
+
if (result.heartbeatIntervalMs > 0) {
|
|
270
|
+
this.heartbeatIntervalMs = result.heartbeatIntervalMs;
|
|
271
|
+
}
|
|
272
|
+
this.registered = true;
|
|
273
|
+
this.registerAttempt = 0;
|
|
274
|
+
} catch {
|
|
275
|
+
const delay = Math.min(
|
|
276
|
+
REGISTER_RETRY_BASE_MS * 2 ** this.registerAttempt,
|
|
277
|
+
REGISTER_RETRY_MAX_MS
|
|
278
|
+
);
|
|
279
|
+
this.registerAttempt += 1;
|
|
280
|
+
this.schedule(delay);
|
|
281
|
+
}
|
|
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
|
+
}
|
|
298
|
+
async heartbeat() {
|
|
299
|
+
const snapshot = this.options.engine.getSnapshot();
|
|
300
|
+
let metrics;
|
|
301
|
+
try {
|
|
302
|
+
metrics = pickMetrics(await this.options.deviceMetrics?.() ?? null);
|
|
303
|
+
} catch {
|
|
304
|
+
metrics = void 0;
|
|
305
|
+
}
|
|
306
|
+
const metricsToSend = this.selectMetricsForHeartbeat(metrics);
|
|
307
|
+
let extras = {};
|
|
308
|
+
try {
|
|
309
|
+
extras = this.options.heartbeatExtras?.() ?? {};
|
|
310
|
+
} catch {
|
|
311
|
+
extras = {};
|
|
312
|
+
}
|
|
313
|
+
let result;
|
|
314
|
+
try {
|
|
315
|
+
result = await this.options.transport.heartbeat({
|
|
316
|
+
...this.options.bootstrapInput,
|
|
317
|
+
agentState: snapshot.state,
|
|
318
|
+
currentSessionId: snapshot.sessionId,
|
|
319
|
+
...this.options.deviceInfo?.appVersion ? { appVersion: this.options.deviceInfo.appVersion } : {},
|
|
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 } : {}
|
|
326
|
+
});
|
|
327
|
+
} catch {
|
|
328
|
+
this.heartbeatFailures += 1;
|
|
329
|
+
if (this.heartbeatFailures >= 3) {
|
|
330
|
+
this.registered = false;
|
|
331
|
+
this.heartbeatFailures = 0;
|
|
332
|
+
}
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.heartbeatFailures = 0;
|
|
336
|
+
this.serverBeatHintMs = typeof result.beatIntervalMs === "number" && result.beatIntervalMs > 0 ? result.beatIntervalMs : void 0;
|
|
337
|
+
if (result.controlSession && !this.controlChannel) {
|
|
338
|
+
this.openControlChannel(result.controlSession);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
openControlChannel(grant) {
|
|
342
|
+
let channel;
|
|
343
|
+
try {
|
|
344
|
+
channel = (this.options.createControlChannel ?? defaultCreateControlChannel)(
|
|
345
|
+
grant.convexUrl
|
|
346
|
+
);
|
|
347
|
+
} catch {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
this.controlChannel = channel;
|
|
351
|
+
this.controlSessionId = grant.id;
|
|
352
|
+
void channel.attach(grant.id, grant.token).then(() => {
|
|
353
|
+
if (this.controlChannel !== channel) return;
|
|
354
|
+
this.controlUnsubscribers.push(
|
|
355
|
+
channel.onCommands(grant.id, grant.token, (commands) => {
|
|
356
|
+
void this.executeCommands(channel, grant, commands);
|
|
357
|
+
}),
|
|
358
|
+
channel.onState(grant.id, grant.token, (state) => {
|
|
359
|
+
if (state.status === "ended" || state.status === "expired") {
|
|
360
|
+
this.closeControlChannel();
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
);
|
|
364
|
+
}).catch(() => {
|
|
365
|
+
if (this.controlChannel === channel) this.closeControlChannel();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
closeControlChannel() {
|
|
369
|
+
for (const unsubscribe of this.controlUnsubscribers) {
|
|
370
|
+
try {
|
|
371
|
+
unsubscribe();
|
|
372
|
+
} catch {
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
this.controlUnsubscribers = [];
|
|
376
|
+
try {
|
|
377
|
+
this.controlChannel?.close();
|
|
378
|
+
} catch {
|
|
379
|
+
}
|
|
380
|
+
this.controlChannel = null;
|
|
381
|
+
this.controlSessionId = null;
|
|
382
|
+
this.handledCommandIds.clear();
|
|
383
|
+
}
|
|
384
|
+
async executeCommands(channel, grant, commands) {
|
|
385
|
+
for (const command of commands) {
|
|
386
|
+
if (this.controlChannel !== channel) return;
|
|
387
|
+
if (this.handledCommandIds.has(command.id)) continue;
|
|
388
|
+
this.handledCommandIds.add(command.id);
|
|
389
|
+
if (this.executing) {
|
|
390
|
+
await this.safeAck(channel, grant, command.id, "failed", "Thi\u1EBFt b\u1ECB \u0111ang b\u1EADn");
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
this.executing = true;
|
|
394
|
+
try {
|
|
395
|
+
await this.executeCommand(channel, grant, command);
|
|
396
|
+
} finally {
|
|
397
|
+
this.executing = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async executeCommand(channel, grant, command) {
|
|
402
|
+
if (command.expiresAt <= Date.now()) {
|
|
403
|
+
await this.safeAck(channel, grant, command.id, "failed", "L\u1EC7nh \u0111\xE3 h\u1EBFt h\u1EA1n");
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
await this.safeAck(channel, grant, command.id, "acked");
|
|
407
|
+
try {
|
|
408
|
+
if (command.type === "start_livestream") {
|
|
409
|
+
await this.runStartLivestream(command);
|
|
410
|
+
const sessionId = this.options.engine.getSnapshot().sessionId;
|
|
411
|
+
await this.safeAck(
|
|
412
|
+
channel,
|
|
413
|
+
grant,
|
|
414
|
+
command.id,
|
|
415
|
+
"completed",
|
|
416
|
+
void 0,
|
|
417
|
+
sessionId ?? void 0
|
|
418
|
+
);
|
|
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");
|
|
426
|
+
} else {
|
|
427
|
+
await this.runStopLivestream();
|
|
428
|
+
await this.safeAck(channel, grant, command.id, "completed");
|
|
429
|
+
}
|
|
430
|
+
} catch (error) {
|
|
431
|
+
await this.safeAck(
|
|
432
|
+
channel,
|
|
433
|
+
grant,
|
|
434
|
+
command.id,
|
|
435
|
+
"failed",
|
|
436
|
+
normalizeError(error).message
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async runStartLivestream(command) {
|
|
441
|
+
const engine = this.options.engine;
|
|
442
|
+
const state = engine.getSnapshot().state;
|
|
443
|
+
if (state !== "idle" && state !== "ended" && state !== "preview") {
|
|
444
|
+
throw new Error(`Thi\u1EBFt b\u1ECB kh\xF4ng s\u1EB5n s\xE0ng (tr\u1EA1ng th\xE1i ${state})`);
|
|
445
|
+
}
|
|
446
|
+
if (state !== "preview") {
|
|
447
|
+
await engine.prepare();
|
|
448
|
+
}
|
|
449
|
+
const remote = this.options.remoteStart;
|
|
450
|
+
const courtRef = command.payload.courtRef?.trim();
|
|
451
|
+
await engine.start(
|
|
452
|
+
{
|
|
453
|
+
externalSessionId: `remote-${command.id}`,
|
|
454
|
+
title: command.payload.title?.trim() || remote.defaultTitle || "Livestream",
|
|
455
|
+
visibility: command.payload.visibility ?? remote.visibility ?? "public",
|
|
456
|
+
consentVersion: remote.consentVersion,
|
|
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
|
+
},
|
|
463
|
+
standby: true
|
|
464
|
+
},
|
|
465
|
+
{ autoPublish: false }
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
async runStopLivestream() {
|
|
469
|
+
const engine = this.options.engine;
|
|
470
|
+
if (engine.getSnapshot().sessionId) {
|
|
471
|
+
await engine.stop("user");
|
|
472
|
+
}
|
|
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
|
+
}
|
|
504
|
+
async safeAck(channel, grant, commandId, status, errorMessage, resultSessionId) {
|
|
505
|
+
try {
|
|
506
|
+
await channel.ack({
|
|
507
|
+
controlSessionId: grant.id,
|
|
508
|
+
token: grant.token,
|
|
509
|
+
commandId,
|
|
510
|
+
status,
|
|
511
|
+
...errorMessage !== void 0 ? { errorMessage } : {},
|
|
512
|
+
...resultSessionId !== void 0 ? { resultSessionId } : {}
|
|
513
|
+
});
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// src/device-agent/http-transport.ts
|
|
520
|
+
var defaultPaths = {
|
|
521
|
+
register: "/devices/register",
|
|
522
|
+
heartbeat: "/devices/heartbeat"
|
|
523
|
+
};
|
|
524
|
+
function createHttpDeviceAgentTransport(options) {
|
|
525
|
+
const baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
526
|
+
if (baseUrl === "") {
|
|
527
|
+
throw new ExpoSdkConfigurationError("baseUrl must be a non-empty string");
|
|
528
|
+
}
|
|
529
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
530
|
+
if (typeof fetchImplementation !== "function") {
|
|
531
|
+
throw new ExpoSdkConfigurationError("A Fetch API implementation is required");
|
|
532
|
+
}
|
|
533
|
+
const paths = { ...defaultPaths, ...options.paths };
|
|
534
|
+
async function post(path, body) {
|
|
535
|
+
const controller = new AbortController();
|
|
536
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 1e4);
|
|
537
|
+
try {
|
|
538
|
+
const extraHeaders = typeof options.headers === "function" ? await options.headers() : options.headers ?? {};
|
|
539
|
+
const response = await fetchImplementation(
|
|
540
|
+
`${baseUrl}/${path.replace(/^\/+/, "")}`,
|
|
541
|
+
{
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: { "Content-Type": "application/json", ...extraHeaders },
|
|
544
|
+
body: JSON.stringify(body),
|
|
545
|
+
signal: controller.signal,
|
|
546
|
+
redirect: "error"
|
|
547
|
+
}
|
|
548
|
+
);
|
|
549
|
+
const envelope = await response.json().catch(() => {
|
|
550
|
+
throw new InvalidResponseError();
|
|
551
|
+
});
|
|
552
|
+
if (!response.ok) throw parseProxyError(envelope);
|
|
553
|
+
if (!isRecord(envelope) || !("data" in envelope)) {
|
|
554
|
+
throw new InvalidResponseError();
|
|
555
|
+
}
|
|
556
|
+
return envelope.data;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (controller.signal.aborted) throw new ExpoSdkTimeoutError();
|
|
559
|
+
if (error instanceof PickleballExpoError) throw error;
|
|
560
|
+
if (error instanceof TypeError) {
|
|
561
|
+
throw new NetworkUnavailableError(void 0, { cause: error });
|
|
562
|
+
}
|
|
563
|
+
throw error;
|
|
564
|
+
} finally {
|
|
565
|
+
clearTimeout(timeout);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
async register(input) {
|
|
570
|
+
const data = await post(paths.register, input);
|
|
571
|
+
if (!isRecord(data) || typeof data.deviceId !== "string" || typeof data.heartbeatIntervalMs !== "number" || !Number.isFinite(data.heartbeatIntervalMs)) {
|
|
572
|
+
throw new InvalidResponseError();
|
|
573
|
+
}
|
|
574
|
+
options.onData?.("register", data);
|
|
575
|
+
return {
|
|
576
|
+
deviceId: data.deviceId,
|
|
577
|
+
heartbeatIntervalMs: data.heartbeatIntervalMs
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
async heartbeat(input) {
|
|
581
|
+
const data = await post(paths.heartbeat, input);
|
|
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 } : {};
|
|
585
|
+
const grant = data.controlSession;
|
|
586
|
+
if (grant === null || grant === void 0) {
|
|
587
|
+
return { controlSession: null, ...beatIntervalMs };
|
|
588
|
+
}
|
|
589
|
+
if (!isRecord(grant) || typeof grant.id !== "string" || typeof grant.token !== "string" || typeof grant.convexUrl !== "string" || grant.convexUrl === "") {
|
|
590
|
+
throw new InvalidResponseError();
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
controlSession: {
|
|
594
|
+
id: grant.id,
|
|
595
|
+
token: grant.token,
|
|
596
|
+
convexUrl: grant.convexUrl
|
|
597
|
+
},
|
|
598
|
+
...beatIntervalMs
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function parseProxyError(envelope) {
|
|
604
|
+
if (isRecord(envelope) && isRecord(envelope.error) && typeof envelope.error.code === "string") {
|
|
605
|
+
return errorFromCode(
|
|
606
|
+
envelope.error.code,
|
|
607
|
+
typeof envelope.error.message === "string" ? envelope.error.message : void 0
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
return new InvalidResponseError();
|
|
611
|
+
}
|
|
612
|
+
function isRecord(value) {
|
|
613
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
614
|
+
}
|
|
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
|
+
|
|
846
|
+
// src/device-agent/index.ts
|
|
847
|
+
init_convex_control_channel();
|
|
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
|
+
|
|
921
|
+
// src/device-agent/component.tsx
|
|
922
|
+
import { useEffect, useRef } from "react";
|
|
923
|
+
function PickleballDeviceAgent(props) {
|
|
924
|
+
const host = useLivestreamHost();
|
|
925
|
+
const propsRef = useRef(props);
|
|
926
|
+
propsRef.current = props;
|
|
927
|
+
useEffect(() => {
|
|
928
|
+
const agent = new DeviceAgent({
|
|
929
|
+
engine: host.engine,
|
|
930
|
+
bootstrapInput: host.bootstrapInput,
|
|
931
|
+
deviceMetrics: host.deviceMetrics,
|
|
932
|
+
transport: propsRef.current.transport,
|
|
933
|
+
remoteStart: propsRef.current.remoteStart,
|
|
934
|
+
deviceInfo: propsRef.current.deviceInfo,
|
|
935
|
+
createControlChannel: propsRef.current.createControlChannel,
|
|
936
|
+
onRemoteSessionStarted: (sessionId) => propsRef.current.onRemoteSessionStarted?.(sessionId),
|
|
937
|
+
heartbeatExtras: () => propsRef.current.heartbeatExtras?.(),
|
|
938
|
+
mqttHealth: () => propsRef.current.mqttHealth?.() ?? null
|
|
939
|
+
});
|
|
940
|
+
agent.start();
|
|
941
|
+
return () => agent.stop();
|
|
942
|
+
}, [host]);
|
|
943
|
+
return null;
|
|
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
|
+
}
|
|
1412
|
+
export {
|
|
1413
|
+
BEAT_FAST_MS,
|
|
1414
|
+
BEAT_SLOW_MS,
|
|
1415
|
+
DeviceAgent,
|
|
1416
|
+
PickleballDeviceAgent,
|
|
1417
|
+
TournamentController,
|
|
1418
|
+
createConvexCommandSubscription,
|
|
1419
|
+
createConvexControlChannel,
|
|
1420
|
+
createConvexDeviceRealtime,
|
|
1421
|
+
createHttpDeviceAgentTransport,
|
|
1422
|
+
createMqttChannel,
|
|
1423
|
+
metricsCrossedThreshold,
|
|
1424
|
+
mqttBeatHealthy,
|
|
1425
|
+
nextBeatDelayMs,
|
|
1426
|
+
withConvexBeat
|
|
1427
|
+
};
|