@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,4 @@
1
+ import { nextBeatDelayMs, type MqttBeatHealth } from "./beat-policy";
1
2
  import type { BootstrapInput } from "../contracts";
2
3
  import type { DeviceMetricsSample, LivestreamEngine } from "../engine";
3
4
  import { normalizeError } from "../errors";
@@ -19,8 +20,19 @@ export interface DeviceAgentDeviceInfo {
19
20
 
20
21
  export interface DeviceCommand {
21
22
  id: string;
22
- type: "start_livestream" | "stop_livestream";
23
- payload: { title?: string; visibility?: "public" | "private" };
23
+ type:
24
+ | "start_livestream"
25
+ | "stop_livestream"
26
+ // standby ↔ live cho phiên đang có — đường thay LiveKit data channel
27
+ // của goLive khi deliveryMode="device-rtmp".
28
+ | "publish_livestream"
29
+ | "unpublish_livestream";
30
+ payload: {
31
+ title?: string;
32
+ visibility?: "public" | "private";
33
+ /** Arm-từ-xa chế độ giải đấu: phiên standby sẽ đậu vào sân này. */
34
+ courtRef?: string;
35
+ };
24
36
  expiresAt: number;
25
37
  }
26
38
 
@@ -30,20 +42,67 @@ export interface DeviceControlSessionGrant {
30
42
  convexUrl: string;
31
43
  }
32
44
 
45
+ /**
46
+ * Một lần tiến trình app chết trước đó — Android `ApplicationExitInfo`, native
47
+ * đã quy đổi `reasonName` ("PACKAGE_UPDATED", "CRASH", "ANR", "OTHER"…). Xem
48
+ * NativeProcessExit của @pickleball/rtmp-native; iOS không có nguồn tương đương.
49
+ */
50
+ export interface DeviceProcessExit {
51
+ /** epoch ms lúc chết. */
52
+ at: number;
53
+ reason: number;
54
+ reasonName: string;
55
+ /** Chuỗi hệ thống, VD "stop com.google.android.webview due to installPackageLI". */
56
+ description?: string;
57
+ /** IMPORTANCE_* lúc chết — 100 = đang ở tiền cảnh. */
58
+ importance?: number;
59
+ pid?: number;
60
+ }
61
+
33
62
  export interface DeviceHeartbeatBody {
34
63
  agentState?: string;
35
64
  currentSessionId?: string | null;
36
65
  appVersion?: string;
37
66
  metrics?: Record<string, number | string>;
67
+ /**
68
+ * Hộp đen: lý do các lần tiến trình chết gần đây. App chỉ cần gửi vài nhịp
69
+ * đầu sau khi mở (server giữ bản mới nhất, ghi idempotent theo mốc).
70
+ */
71
+ recentExits?: DeviceProcessExit[];
72
+ /**
73
+ * Trạng thái nguồn app tự khai: "rest" = chế độ nghỉ (camera/service đã tắt,
74
+ * máy đang nguội). Server chỉ ghi khi đổi — dashboard hiện badge "Đang nghỉ".
75
+ */
76
+ powerState?: "rest" | "active";
77
+ /**
78
+ * Xin tỉ số kèm response beat — CHỈ bật khi kênh MQTT đứt (đường nhanh
79
+ * plv1/dev/{id}/score câm); MQTT khoẻ mà vẫn xin là server đọc doc tỉ số
80
+ * mỗi nhịp vô ích.
81
+ */
82
+ wantScore?: boolean;
38
83
  }
39
84
 
85
+ /**
86
+ * Field app tự gắn thêm vào MỖI heartbeat. Hàm được gọi ngay trước khi gửi,
87
+ * trả undefined/{} khi không có gì — ném lỗi thì bị nuốt, heartbeat vẫn đi.
88
+ */
89
+ export type DeviceHeartbeatExtras = Pick<
90
+ DeviceHeartbeatBody,
91
+ "recentExits" | "powerState" | "wantScore"
92
+ >;
93
+
40
94
  export interface DeviceAgentTransport {
41
95
  register(
42
96
  input: BootstrapInput & DeviceAgentDeviceInfo,
43
97
  ): Promise<{ deviceId: string; heartbeatIntervalMs: number }>;
44
- heartbeat(
45
- input: BootstrapInput & DeviceHeartbeatBody,
46
- ): Promise<{ controlSession: DeviceControlSessionGrant | null }>;
98
+ heartbeat(input: BootstrapInput & DeviceHeartbeatBody): Promise<{
99
+ controlSession: DeviceControlSessionGrant | null;
100
+ /**
101
+ * Nhịp server gợi ý cho lượt beat kế (adaptive heartbeat). Optional để
102
+ * transport/server đời cũ vẫn hợp lệ — thiếu thì agent giữ nhịp nhanh.
103
+ */
104
+ beatIntervalMs?: number;
105
+ }>;
47
106
  }
48
107
 
49
108
  /** Kênh điều khiển realtime — mặc định là Convex websocket (convex-control-channel). */
@@ -90,6 +149,14 @@ export interface DeviceAgentOptions {
90
149
  deviceInfo?: DeviceAgentDeviceInfo;
91
150
  onRemoteSessionStarted?: (sessionId: string) => void;
92
151
  deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
152
+ /** Field bổ sung cho heartbeat (VD recentExits) — xem DeviceHeartbeatExtras. */
153
+ heartbeatExtras?: () => DeviceHeartbeatExtras | undefined;
154
+ /**
155
+ * Sức khoẻ kênh MQTT của app (kênh nằm ở tầng app, không thuộc SDK). Có
156
+ * nguồn này VÀ server bật rollout thì nhịp beat giãn 12s → 60s; thiếu nó thì
157
+ * agent chạy y hệt trước đây. Ném lỗi trong callback được coi như "không có".
158
+ */
159
+ mqttHealth?: () => MqttBeatHealth | null;
93
160
  /** Factory kênh điều khiển — test truyền fake; mặc định Convex websocket. */
94
161
  createControlChannel?: (convexUrl: string) => DeviceControlChannel;
95
162
  /** Test hook: nguồn app-state; mặc định react-native AppState. */
@@ -97,8 +164,39 @@ export interface DeviceAgentOptions {
97
164
  }
98
165
 
99
166
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 12_000;
167
+ /**
168
+ * Nhịp heartbeat khi máy RẢNH và chưa có kênh điều khiển.
169
+ *
170
+ * Từng là 4 giây (18/08/2026): lúc đó dashboard là đường điều khiển chính, mà
171
+ * lệnh chỉ tới được sau khi heartbeat trả về control session để app attach —
172
+ * ở nhịp 12s, bấm "Gửi lệnh bắt đầu" phải chờ 12 giây rồi báo thất bại dù lệnh
173
+ * vẫn còn hiệu lực (TTL 60s).
174
+ *
175
+ * Đưa về 12 giây vì hai lẽ:
176
+ *
177
+ * 1. Điều khiển thực tế đã chuyển sang PSS gọi REST, không còn bấm tay trên
178
+ * dashboard. Khe duy nhất còn nhạy nhịp là từ lúc GÁN SÂN tới lúc máy tự
179
+ * arm — mà PSS gán sân ở đầu ngày chứ không gán rồi start ngay.
180
+ * 2. Nhịp 4s là 2 function call mỗi 4 giây MỖI MÁY, chạy suốt ngày đêm kể cả
181
+ * khi không ai dùng. Đây là chi phí nền lớn nhất của đường heartbeat.
182
+ *
183
+ * Lưu ý: nhịp này CHỈ áp dụng khi máy chưa giữ phiên nào. Camera đã gác sân
184
+ * luôn có phiên chờ nên xưa nay vẫn đập theo nhịp thường — xem nextBeatDelay().
185
+ */
186
+ const IDLE_HEARTBEAT_INTERVAL_MS = 12_000;
100
187
  const REGISTER_RETRY_BASE_MS = 2_000;
101
188
  const REGISTER_RETRY_MAX_MS = 60_000;
189
+ /**
190
+ * Metrics (RAM, nhiệt, pin, sóng) nhích gần như mỗi lần đo, nên đính vào MỌI
191
+ * heartbeat là ghi `devices.metricsJson` mỗi nhịp → đánh thức mọi query reactive
192
+ * đọc bảng devices. Chỉ gửi khi CÓ CHUYỆN đáng báo, hoặc mỗi 5 phút làm mốc
193
+ * chẩn đoán nền. Heartbeat vẫn đập đều để giữ "online"; chỉ metrics là thưa.
194
+ */
195
+ const METRICS_BASELINE_MS = 5 * 60_000;
196
+ /** Pin thủng mốc này (đang ≥ mà tụt xuống dưới) → gửi ngay. */
197
+ const METRICS_BATTERY_LOW_PCT = 10;
198
+ /** Pin đổi từ mốc đã gửi ≥ ngần này phần trăm → gửi (bắt tụt/sạc nhanh). */
199
+ const METRICS_BATTERY_DELTA_PCT = 5;
102
200
  /** Chỉ các field server chấp nhận (proxy từ chối key lạ trong metrics). */
103
201
  const METRIC_FIELDS = [
104
202
  "ramFreeMb",
@@ -111,6 +209,8 @@ const METRIC_FIELDS = [
111
209
  "linkMbps",
112
210
  "rssi",
113
211
  "connQuality",
212
+ "signalPct",
213
+ "localIp",
114
214
  ] as const;
115
215
 
116
216
  function defaultSubscribeAppState(listener: AppStateListener): () => void {
@@ -142,18 +242,52 @@ function pickMetrics(
142
242
  return Object.keys(metrics).length > 0 ? metrics : undefined;
143
243
  }
144
244
 
245
+ /**
246
+ * Metrics lần này có đáng gửi so với lần đã gửi không. Hàm thuần để test dễ.
247
+ * Đáng gửi khi: chưa gửi lần nào; pin vừa THỦNG mốc thấp (đang ≥10% tụt xuống
248
+ * <10%); pin đổi ≥5% so với mốc đã gửi; đổi mức nhiệt (thermal) hoặc loại mạng.
249
+ * KHÔNG tính RAM/sóng/nhiệt-độ-pin: chúng nhích liên tục, để mốc 5 phút lo.
250
+ */
251
+ export function metricsCrossedThreshold(
252
+ last: Record<string, number | string> | undefined,
253
+ next: Record<string, number | string>,
254
+ ): boolean {
255
+ if (!last) return true;
256
+ const b0 = typeof last.batteryPct === "number" ? last.batteryPct : undefined;
257
+ const b1 = typeof next.batteryPct === "number" ? next.batteryPct : undefined;
258
+ if (b1 !== undefined) {
259
+ if (
260
+ b1 < METRICS_BATTERY_LOW_PCT &&
261
+ (b0 === undefined || b0 >= METRICS_BATTERY_LOW_PCT)
262
+ ) {
263
+ return true;
264
+ }
265
+ if (b0 !== undefined && Math.abs(b1 - b0) >= METRICS_BATTERY_DELTA_PCT) {
266
+ return true;
267
+ }
268
+ }
269
+ if (next.thermal !== last.thermal) return true;
270
+ if (next.netType !== last.netType) return true;
271
+ return false;
272
+ }
273
+
145
274
  export class DeviceAgent {
146
275
  private readonly options: DeviceAgentOptions;
147
276
  private running = false;
148
277
  private registered = false;
149
278
  private foreground = true;
150
279
  private heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
280
+ /** Nhịp server gợi ý ở beat gần nhất — undefined = server chưa nói gì → 12s. */
281
+ private serverBeatHintMs: number | undefined;
151
282
  private timer: ReturnType<typeof setTimeout> | null = null;
152
283
  private beatInFlight = false;
153
284
  private registerAttempt = 0;
154
285
  private unsubscribeAppState: (() => void) | null = null;
155
286
 
156
287
  private heartbeatFailures = 0;
288
+ /** Metrics đã gửi lần gần nhất + mốc thời gian, để lọc "chỉ gửi khi đổi". */
289
+ private lastSentMetrics?: Record<string, number | string>;
290
+ private lastMetricsSentAt = 0;
157
291
  private controlChannel: DeviceControlChannel | null = null;
158
292
  private controlSessionId: string | null = null;
159
293
  private controlUnsubscribers: Array<() => void> = [];
@@ -198,6 +332,38 @@ export class DeviceAgent {
198
332
  }
199
333
  }
200
334
 
335
+ /**
336
+ * Đang có phiên hoặc đã mở kênh điều khiển = lệnh tới qua kênh, nhịp thường
337
+ * là đủ. Rảnh và chưa có kênh = đang chờ lệnh, đập nhanh cho kịp dashboard.
338
+ */
339
+ private nextBeatDelay(): number {
340
+ const base = this.controlChannel
341
+ ? this.heartbeatIntervalMs
342
+ : this.options.engine.getSnapshot().sessionId
343
+ ? this.heartbeatIntervalMs
344
+ : Math.min(IDLE_HEARTBEAT_INTERVAL_MS, this.heartbeatIntervalMs);
345
+ // Chỉ được giãn khi CHÍNH APP thấy kênh MQTT khoẻ VÀ server cho phép
346
+ // (serverBeatHintMs). Không có nguồn sức khoẻ hoặc chưa có hint → giữ nhịp
347
+ // cũ y hệt trước đây; đây là lý do bật/tắt rollout không cần OTA lại.
348
+ const health = (() => {
349
+ try {
350
+ return this.options.mqttHealth?.() ?? null;
351
+ } catch {
352
+ return null;
353
+ }
354
+ })();
355
+ const adaptive = nextBeatDelayMs({
356
+ health,
357
+ now: Date.now(),
358
+ serverHintMs: this.serverBeatHintMs,
359
+ // Đang chạy lệnh / đang mở kênh điều khiển = có người chờ kết quả.
360
+ busy: this.executing || this.controlChannel !== null,
361
+ });
362
+ // `adaptive` chỉ vượt `base` khi ĐỦ điều kiện giãn; mọi ca còn lại nó bằng
363
+ // 12s nên kết quả rơi về đúng hành vi cũ.
364
+ return Math.max(base, adaptive);
365
+ }
366
+
201
367
  private schedule(delayMs: number) {
202
368
  if (!this.running || !this.foreground) return;
203
369
  if (this.timer) clearTimeout(this.timer);
@@ -215,7 +381,7 @@ export class DeviceAgent {
215
381
  if (!this.registered) return; // register() đã schedule retry
216
382
  }
217
383
  await this.heartbeat();
218
- this.schedule(this.heartbeatIntervalMs);
384
+ this.schedule(this.nextBeatDelay());
219
385
  } finally {
220
386
  this.beatInFlight = false;
221
387
  }
@@ -243,6 +409,24 @@ export class DeviceAgent {
243
409
  }
244
410
  }
245
411
 
412
+ /**
413
+ * Lọc metrics cho heartbeat: chỉ trả (và ghi nhớ) khi vượt ngưỡng
414
+ * (metricsCrossedThreshold) hoặc đã quá METRICS_BASELINE_MS kể từ lần gửi.
415
+ * Trả undefined = nhịp này không kèm metrics.
416
+ */
417
+ private selectMetricsForHeartbeat(
418
+ metrics: Record<string, number | string> | undefined,
419
+ ): Record<string, number | string> | undefined {
420
+ if (!metrics) return undefined;
421
+ const now = Date.now();
422
+ const crossed = metricsCrossedThreshold(this.lastSentMetrics, metrics);
423
+ const baselineDue = now - this.lastMetricsSentAt >= METRICS_BASELINE_MS;
424
+ if (!crossed && !baselineDue) return undefined;
425
+ this.lastSentMetrics = metrics;
426
+ this.lastMetricsSentAt = now;
427
+ return metrics;
428
+ }
429
+
246
430
  private async heartbeat() {
247
431
  const snapshot = this.options.engine.getSnapshot();
248
432
  let metrics: Record<string, number | string> | undefined;
@@ -251,7 +435,20 @@ export class DeviceAgent {
251
435
  } catch {
252
436
  metrics = undefined;
253
437
  }
254
- let result: { controlSession: DeviceControlSessionGrant | null };
438
+ // Chỉ đính metrics khi vượt ngưỡng đáng báo hoặc tới mốc chẩn đoán nền —
439
+ // xem METRICS_BASELINE_MS. Bỏ metrics KHÔNG cản heartbeat: nhịp vẫn đi để
440
+ // giữ "online", chỉ là không kèm số liệu (server khỏi ghi lại metricsJson).
441
+ const metricsToSend = this.selectMetricsForHeartbeat(metrics);
442
+ let extras: DeviceHeartbeatExtras = {};
443
+ try {
444
+ extras = this.options.heartbeatExtras?.() ?? {};
445
+ } catch {
446
+ extras = {};
447
+ }
448
+ let result: {
449
+ controlSession: DeviceControlSessionGrant | null;
450
+ beatIntervalMs?: number;
451
+ };
255
452
  try {
256
453
  result = await this.options.transport.heartbeat({
257
454
  ...this.options.bootstrapInput,
@@ -260,7 +457,14 @@ export class DeviceAgent {
260
457
  ...(this.options.deviceInfo?.appVersion
261
458
  ? { appVersion: this.options.deviceInfo.appVersion }
262
459
  : {}),
263
- ...(metrics ? { metrics } : {}),
460
+ ...(metricsToSend ? { metrics: metricsToSend } : {}),
461
+ ...(extras.recentExits && extras.recentExits.length > 0
462
+ ? { recentExits: extras.recentExits }
463
+ : {}),
464
+ ...(extras.powerState !== undefined ? { powerState: extras.powerState } : {}),
465
+ // Chỉ gửi khi TRUE. Server đọc doc tỉ số mỗi nhịp có cờ này, nên gửi
466
+ // `false` cũng vô hại nhưng phí một field trên mọi beat của mọi máy.
467
+ ...(extras.wantScore ? { wantScore: true } : {}),
264
468
  });
265
469
  } catch {
266
470
  // Lỗi liên tiếp (mạng, hoặc server đã xoá device) → thử re-register.
@@ -273,6 +477,11 @@ export class DeviceAgent {
273
477
  return;
274
478
  }
275
479
  this.heartbeatFailures = 0;
480
+ // Server nói nhịp kế; số lạ/thiếu thì quên đi và giữ nhịp nhanh.
481
+ this.serverBeatHintMs =
482
+ typeof result.beatIntervalMs === "number" && result.beatIntervalMs > 0
483
+ ? result.beatIntervalMs
484
+ : undefined;
276
485
  if (result.controlSession && !this.controlChannel) {
277
486
  this.openControlChannel(result.controlSession);
278
487
  }
@@ -374,6 +583,12 @@ export class DeviceAgent {
374
583
  sessionId ?? undefined,
375
584
  );
376
585
  if (sessionId) this.options.onRemoteSessionStarted?.(sessionId);
586
+ } else if (command.type === "publish_livestream") {
587
+ await this.runPublish();
588
+ await this.safeAck(channel, grant, command.id, "completed");
589
+ } else if (command.type === "unpublish_livestream") {
590
+ await this.runUnpublish();
591
+ await this.safeAck(channel, grant, command.id, "completed");
377
592
  } else {
378
593
  await this.runStopLivestream();
379
594
  await this.safeAck(channel, grant, command.id, "completed");
@@ -399,13 +614,19 @@ export class DeviceAgent {
399
614
  await engine.prepare();
400
615
  }
401
616
  const remote = this.options.remoteStart;
617
+ const courtRef = command.payload.courtRef?.trim();
402
618
  await engine.start(
403
619
  {
404
620
  externalSessionId: `remote-${command.id}`,
405
621
  title: command.payload.title?.trim() || remote.defaultTitle || "Livestream",
406
622
  visibility: command.payload.visibility ?? remote.visibility ?? "public",
407
623
  consentVersion: remote.consentVersion,
408
- metadata: { ...remote.metadata, deviceCommandId: command.id },
624
+ metadata: {
625
+ ...remote.metadata,
626
+ deviceCommandId: command.id,
627
+ // courtRef trong metadata → server đậu phiên standby vào sân
628
+ ...(courtRef ? { courtRef } : {}),
629
+ },
409
630
  standby: true,
410
631
  },
411
632
  { autoPublish: false },
@@ -419,6 +640,38 @@ export class DeviceAgent {
419
640
  }
420
641
  }
421
642
 
643
+ /**
644
+ * standby → live theo lệnh dashboard. startPublishing() NUỐT lỗi (recordError
645
+ * vào snapshot thay vì throw) nên phải kiểm state sau khi gọi — không kiểm là
646
+ * lệnh được ack "completed" dù thiết bị chưa hề phát.
647
+ */
648
+ private async runPublish() {
649
+ const engine = this.options.engine;
650
+ const state = engine.getSnapshot().state;
651
+ if (state === "live") return; // đã phát — lệnh lặp coi như xong
652
+ if (state !== "standby") {
653
+ throw new Error(`Thiết bị không ở chế độ chờ (trạng thái ${state})`);
654
+ }
655
+ await engine.startPublishing();
656
+ if (engine.getSnapshot().state !== "live") {
657
+ const message = engine.getSnapshot().error?.message;
658
+ throw new Error(message ? `Không phát được: ${message}` : "Không phát được");
659
+ }
660
+ }
661
+
662
+ private async runUnpublish() {
663
+ const engine = this.options.engine;
664
+ const state = engine.getSnapshot().state;
665
+ if (state === "standby") return; // đã ở chế độ chờ
666
+ if (state !== "live") {
667
+ throw new Error(`Thiết bị không đang phát (trạng thái ${state})`);
668
+ }
669
+ await engine.stopPublishing();
670
+ if (engine.getSnapshot().state !== "standby") {
671
+ throw new Error("Không dừng phát được");
672
+ }
673
+ }
674
+
422
675
  private async safeAck(
423
676
  channel: DeviceControlChannel,
424
677
  grant: DeviceControlSessionGrant,
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Nhịp heartbeat thích ứng — bản CHÉP của luật trong
3
+ * `packages/shared/src/realtime.ts`.
4
+ *
5
+ * Vì sao chép chứ không import: gói expo-sdk cố ý KHÔNG phụ thuộc
6
+ * `@pickleball/shared` (guard `scripts/verify-pack.mjs` chặn), để app đối tác
7
+ * cài SDK không phải kéo theo cả gói dùng chung của backend. Đổi luật ở đây thì
8
+ * phải đổi cả hai nơi — bộ test hai bên dùng CÙNG bảng giá trị để phát hiện lệch.
9
+ */
10
+
11
+ export const BEAT_SLOW_MS = 60_000;
12
+ export const BEAT_FAST_MS = 12_000;
13
+ export const MQTT_HEALTHY_SILENCE_MS = 45_000;
14
+ export const MQTT_HEALTHY_HOLD_MS = 120_000;
15
+
16
+ export interface MqttBeatHealth {
17
+ connected: boolean;
18
+ subscribed: boolean;
19
+ lastInboundAt: number;
20
+ healthySince: number;
21
+ }
22
+
23
+ /** Đủ CẢ BỐN vế mới được coi là kênh khoẻ. */
24
+ export function mqttBeatHealthy(h: MqttBeatHealth, now: number): boolean {
25
+ if (!h.connected || !h.subscribed) return false;
26
+ if (h.lastInboundAt <= 0 || now - h.lastInboundAt >= MQTT_HEALTHY_SILENCE_MS) return false;
27
+ if (h.healthySince <= 0 || now - h.healthySince < MQTT_HEALTHY_HOLD_MS) return false;
28
+ return true;
29
+ }
30
+
31
+ /**
32
+ * Nhịp kế tiếp = min(luật nội bộ, hint của server), và THIẾU hint thì mặc định
33
+ * nhanh. Nhờ vậy máy chưa nhận được hint (server cũ, hoặc beat lỗi) không bao
34
+ * giờ tự ý giãn nhịp, còn tắt rollout chỉ là đổi một biến env phía server.
35
+ */
36
+ export function nextBeatDelayMs(input: {
37
+ health: MqttBeatHealth | null;
38
+ now: number;
39
+ serverHintMs?: number;
40
+ busy?: boolean;
41
+ }): number {
42
+ const hint = input.serverHintMs ?? BEAT_FAST_MS;
43
+ if (input.busy || !input.health) return BEAT_FAST_MS;
44
+ const local = mqttBeatHealthy(input.health, input.now) ? BEAT_SLOW_MS : BEAT_FAST_MS;
45
+ return Math.min(local, hint);
46
+ }
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useRef } from "react";
2
+ import type { MqttBeatHealth } from "./beat-policy";
2
3
  import { useLivestreamHost } from "../provider";
3
4
  import {
4
5
  DeviceAgent,
5
6
  type DeviceAgentDeviceInfo,
6
7
  type DeviceAgentTransport,
7
8
  type DeviceControlChannel,
9
+ type DeviceHeartbeatExtras,
8
10
  type RemoteStartConfig,
9
11
  } from "./agent";
10
12
 
@@ -20,6 +22,13 @@ export interface PickleballDeviceAgentProps {
20
22
  deviceInfo?: DeviceAgentDeviceInfo;
21
23
  onRemoteSessionStarted?: (sessionId: string) => void;
22
24
  createControlChannel?: (convexUrl: string) => DeviceControlChannel;
25
+ /** Field bổ sung cho heartbeat (VD recentExits) — đọc qua ref nên đổi hàm không dựng lại agent. */
26
+ heartbeatExtras?: () => DeviceHeartbeatExtras | undefined;
27
+ /**
28
+ * Sức khoẻ kênh MQTT của app — nguồn để agent giãn nhịp beat khi kênh khoẻ
29
+ * VÀ server bật rollout. Bỏ trống = agent giữ nhịp cũ.
30
+ */
31
+ mqttHealth?: () => MqttBeatHealth | null;
23
32
  }
24
33
 
25
34
  export function PickleballDeviceAgent(props: PickleballDeviceAgentProps) {
@@ -40,6 +49,8 @@ export function PickleballDeviceAgent(props: PickleballDeviceAgentProps) {
40
49
  createControlChannel: propsRef.current.createControlChannel,
41
50
  onRemoteSessionStarted: (sessionId) =>
42
51
  propsRef.current.onRemoteSessionStarted?.(sessionId),
52
+ heartbeatExtras: () => propsRef.current.heartbeatExtras?.(),
53
+ mqttHealth: () => propsRef.current.mqttHealth?.() ?? null,
43
54
  });
44
55
  agent.start();
45
56
  return () => agent.stop();
@@ -0,0 +1,140 @@
1
+ import type { BootstrapInput } from "../contracts";
2
+ import type {
3
+ DeviceAgentDeviceInfo,
4
+ DeviceAgentTransport,
5
+ DeviceControlSessionGrant,
6
+ DeviceHeartbeatBody,
7
+ } from "./agent";
8
+ import type { ConvexDeviceRealtime } from "./convex-command-subscription";
9
+
10
+ /**
11
+ * Bọc transport HTTP để heartbeat đi đường WEBSOCKET (mutation deviceApp.beat,
12
+ * 1 function call) thay vì POST /devices/heartbeat (httpAction→runMutation,
13
+ * 2 call). Ở 1.000 máy nhịp 12s, đó là 216M call/tháng so với 108M — một nửa
14
+ * hoá đơn chỉ bằng cách đổi đường vận chuyển, không đổi ngữ nghĩa.
15
+ *
16
+ * register/pairing GIỮ HTTP: đó là lúc máy CHƯA có deviceToken (chưa auth WS
17
+ * được), và là việc hiếm — không đáng tối ưu.
18
+ *
19
+ * Luật fallback: mọi lỗi WS (mất mạng, token hỏng, server chưa deploy beat...)
20
+ * → rơi về HTTP NGAY trong nhịp đó, và nghỉ thử WS một quãng để mỗi beat
21
+ * không phải trả thêm một lần timeout khi WS đang hỏng kéo dài. Máy bản này chạy
22
+ * với server cũ chưa có `deviceApp.beat` vẫn sống bình thường qua HTTP.
23
+ */
24
+
25
+ /** WS hỏng thì nghỉ thử ngần này rồi mới thử lại — tránh mỗi beat +10s timeout. */
26
+ const WS_BEAT_RETRY_MS = 60_000;
27
+ /**
28
+ * Trần chờ một lượt beat WS. PHẢI có: ConvexClient.mutation KHÔNG tự timeout —
29
+ * mất mạng là nó xếp hàng chờ reconnect vô hạn, thiếu trần này thì heartbeat
30
+ * treo và không bao giờ rơi về HTTP. Mutation xếp hàng có thể vẫn chạy SAU khi
31
+ * mạng về → beat đúp — vô hại, heartbeat lặp là ngữ nghĩa bình thường.
32
+ */
33
+ const WS_BEAT_TIMEOUT_MS = 10_000;
34
+
35
+ export interface ConvexBeatOptions {
36
+ /** deviceToken hiện tại (đọc mỗi nhịp — token có thể vừa được cấp/đổi). */
37
+ getDeviceToken: () => string | null | undefined;
38
+ /**
39
+ * Cùng hook với createHttpDeviceAgentTransport.onData: đường WS phải đổ
40
+ * envelope y hệt để handleTransportData (claim/pairing/assignedCourt/
41
+ * activeSession/preview) không phân biệt beat đến từ đường nào.
42
+ */
43
+ onData?: (endpoint: "heartbeat", data: Record<string, unknown>) => void;
44
+ timeoutMs?: number;
45
+ /** Test hook. */
46
+ now?: () => number;
47
+ }
48
+
49
+ export function withConvexBeat(
50
+ base: DeviceAgentTransport,
51
+ realtime: ConvexDeviceRealtime,
52
+ options: ConvexBeatOptions,
53
+ ): DeviceAgentTransport {
54
+ const now = options.now ?? Date.now;
55
+ const timeoutMs = options.timeoutMs ?? WS_BEAT_TIMEOUT_MS;
56
+ let wsBrokenUntil = 0;
57
+
58
+ return {
59
+ register(input: BootstrapInput & DeviceAgentDeviceInfo) {
60
+ return base.register(input);
61
+ },
62
+ async heartbeat(input: BootstrapInput & DeviceHeartbeatBody) {
63
+ const deviceToken = options.getDeviceToken();
64
+ if (!deviceToken || now() < wsBrokenUntil) {
65
+ return base.heartbeat(input);
66
+ }
67
+ try {
68
+ const data = await withTimeout(
69
+ realtime.beat({
70
+ deviceToken,
71
+ // Server tra máy theo token — bootstrapInput (installationId…) chỉ
72
+ // đường HTTP cần. Chỉ gửi đúng phần mutation beat nhận.
73
+ agentState: input.agentState,
74
+ currentSessionId: input.currentSessionId,
75
+ ...(input.appVersion !== undefined ? { appVersion: input.appVersion } : {}),
76
+ ...(input.metrics !== undefined ? { metrics: input.metrics } : {}),
77
+ ...(input.recentExits !== undefined && input.recentExits.length > 0
78
+ ? { recentExits: input.recentExits }
79
+ : {}),
80
+ ...(input.powerState !== undefined ? { powerState: input.powerState } : {}),
81
+ }),
82
+ timeoutMs,
83
+ );
84
+ options.onData?.("heartbeat", data);
85
+ // Cùng lý do với http-transport: transport là nơi DUY NHẤT dữ liệu beat
86
+ // đi qua, không chuyển tiếp ở đây là agent vĩnh viễn không thấy hint.
87
+ return {
88
+ controlSession: parseControlSession(data.controlSession),
89
+ ...(typeof data.beatIntervalMs === "number" &&
90
+ Number.isFinite(data.beatIntervalMs)
91
+ ? { beatIntervalMs: data.beatIntervalMs }
92
+ : {}),
93
+ };
94
+ } catch {
95
+ // Không phân loại lỗi: nguyên nhân nào thì hành động cũng là dùng HTTP
96
+ // nhịp này và nghỉ thử WS một quãng.
97
+ wsBrokenUntil = now() + WS_BEAT_RETRY_MS;
98
+ return base.heartbeat(input);
99
+ }
100
+ },
101
+ };
102
+ }
103
+
104
+ /** Cùng luật parse với đường HTTP — grant lệch dạng coi như beat hỏng (throw). */
105
+ function parseControlSession(grant: unknown): DeviceControlSessionGrant | null {
106
+ if (grant === null || grant === undefined) return null;
107
+ if (
108
+ grant === null ||
109
+ typeof grant !== "object" ||
110
+ typeof (grant as Record<string, unknown>).id !== "string" ||
111
+ typeof (grant as Record<string, unknown>).token !== "string" ||
112
+ typeof (grant as Record<string, unknown>).convexUrl !== "string" ||
113
+ (grant as Record<string, unknown>).convexUrl === ""
114
+ ) {
115
+ throw new Error("Invalid controlSession in beat response");
116
+ }
117
+ const record = grant as Record<string, unknown>;
118
+ return {
119
+ id: record.id as string,
120
+ token: record.token as string,
121
+ convexUrl: record.convexUrl as string,
122
+ };
123
+ }
124
+
125
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
126
+ return new Promise<T>((resolve, reject) => {
127
+ const timer = setTimeout(() => reject(new Error("beat timeout")), ms);
128
+ (timer as unknown as { unref?: () => void }).unref?.();
129
+ promise.then(
130
+ (value) => {
131
+ clearTimeout(timer);
132
+ resolve(value);
133
+ },
134
+ (error) => {
135
+ clearTimeout(timer);
136
+ reject(error);
137
+ },
138
+ );
139
+ });
140
+ }