@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +155 -101
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +14 -2
  6. package/dist/advanced.d.cts +2 -2
  7. package/dist/advanced.d.ts +2 -2
  8. package/dist/advanced.js +17 -5
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-62YV3PCD.cjs +139 -0
  12. package/dist/chunk-6F3HN6US.js +50 -0
  13. package/dist/chunk-6YE5BL3A.cjs +50 -0
  14. package/dist/chunk-7Z7TN5N3.js +318 -0
  15. package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
  16. package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
  17. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  18. package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
  19. package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
  20. package/dist/chunk-ZSKSXOCZ.js +139 -0
  21. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  22. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  23. package/dist/device-agent/index.cjs +973 -44
  24. package/dist/device-agent/index.d.cts +339 -3
  25. package/dist/device-agent/index.d.ts +339 -3
  26. package/dist/device-agent/index.js +940 -11
  27. package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
  28. package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
  29. package/dist/index.cjs +419 -937
  30. package/dist/index.d.cts +315 -32
  31. package/dist/index.d.ts +315 -32
  32. package/dist/index.js +423 -941
  33. package/dist/plugin.cjs +11 -13
  34. package/dist/plugin.d.cts +15 -1
  35. package/dist/plugin.d.ts +15 -1
  36. package/dist/plugin.js +7 -9
  37. package/dist/realtime/index.cjs +31 -0
  38. package/dist/realtime/index.d.cts +15 -0
  39. package/dist/realtime/index.d.ts +15 -0
  40. package/dist/realtime/index.js +31 -0
  41. package/dist/ui/index.cjs +1297 -0
  42. package/dist/ui/index.d.cts +493 -0
  43. package/dist/ui/index.d.ts +493 -0
  44. package/dist/ui/index.js +1297 -0
  45. package/dist/version.cjs +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +31 -15
  48. package/src/advanced.ts +11 -9
  49. package/src/auto-resume.tsx +259 -0
  50. package/src/camera-controls.ts +99 -0
  51. package/src/camera-grant-provider.ts +392 -0
  52. package/src/camera-settings.ts +133 -0
  53. package/src/contracts.ts +73 -0
  54. package/src/device-agent/agent.ts +262 -9
  55. package/src/device-agent/beat-policy.ts +46 -0
  56. package/src/device-agent/component.tsx +11 -0
  57. package/src/device-agent/convex-beat-transport.ts +140 -0
  58. package/src/device-agent/convex-command-subscription.ts +101 -0
  59. package/src/device-agent/convex-control-channel.ts +7 -1
  60. package/src/device-agent/http-transport.ts +22 -1
  61. package/src/device-agent/index.ts +22 -0
  62. package/src/device-agent/mqtt-channel.ts +379 -0
  63. package/src/device-agent/mqtt-codec.ts +347 -0
  64. package/src/device-agent/tournament.ts +352 -0
  65. package/src/engine.ts +580 -15
  66. package/src/http-session-provider.ts +21 -189
  67. package/src/index.ts +84 -5
  68. package/src/local-video-quality.ts +43 -0
  69. package/src/native-runtime.ts +234 -78
  70. package/src/plugin.ts +19 -6
  71. package/src/provider.tsx +94 -18
  72. package/src/realtime/index.ts +56 -0
  73. package/src/resume-policy.ts +85 -0
  74. package/src/resume.ts +132 -0
  75. package/src/rtmp-room-adapter.ts +164 -12
  76. package/src/session-grant.ts +256 -0
  77. package/src/ui/camera-screen.tsx +484 -0
  78. package/src/ui/controls-sheet.tsx +445 -0
  79. package/src/ui/copy.ts +326 -0
  80. package/src/ui/helpers.ts +105 -0
  81. package/src/ui/hooks.ts +59 -0
  82. package/src/ui/index.ts +42 -0
  83. package/src/ui/preview-view.tsx +39 -0
  84. package/src/ui/primitives.tsx +304 -0
  85. package/src/uplink-check.ts +173 -0
  86. package/dist/advanced-BXDrDl1h.d.ts +0 -316
  87. package/dist/advanced-BdAa2Erv.d.cts +0 -316
  88. package/src/livekit-room-adapter.ts +0 -390
  89. 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-FODE2W2H.js";
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.heartbeatIntervalMs);
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
- ...metrics ? { metrics } : {}
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: { ...remote.metadata, deviceCommandId: command.id },
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) return { controlSession: null };
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
- createHttpDeviceAgentTransport
1420
+ createConvexDeviceRealtime,
1421
+ createHttpDeviceAgentTransport,
1422
+ createMqttChannel,
1423
+ metricsCrossedThreshold,
1424
+ mqttBeatHealthy,
1425
+ nextBeatDelayMs,
1426
+ withConvexBeat
498
1427
  };