@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.
Files changed (117) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +163 -40
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +47 -0
  6. package/dist/advanced.d.cts +3 -0
  7. package/dist/advanced.d.ts +3 -0
  8. package/dist/advanced.js +47 -0
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-2JHULJZ7.js +265 -0
  12. package/dist/chunk-62YV3PCD.cjs +139 -0
  13. package/dist/chunk-6F3HN6US.js +50 -0
  14. package/dist/chunk-6YE5BL3A.cjs +50 -0
  15. package/dist/chunk-7OX2DSKI.cjs +33 -0
  16. package/dist/chunk-7Z7TN5N3.js +318 -0
  17. package/dist/chunk-DHDX3ZOO.js +800 -0
  18. package/dist/chunk-GXQYMPHE.cjs +9 -0
  19. package/dist/chunk-IUIRVKJI.cjs +2290 -0
  20. package/dist/chunk-KFQGP6VL.js +33 -0
  21. package/dist/chunk-LCWGNGEN.cjs +265 -0
  22. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  23. package/dist/chunk-XGP2MN5R.js +2290 -0
  24. package/dist/chunk-XL4CISVQ.js +9 -0
  25. package/dist/chunk-YGSQ25UA.cjs +800 -0
  26. package/dist/chunk-ZSKSXOCZ.js +139 -0
  27. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  28. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  29. package/dist/device-agent/index.cjs +1427 -0
  30. package/dist/device-agent/index.d.cts +493 -0
  31. package/dist/device-agent/index.d.ts +493 -0
  32. package/dist/device-agent/index.js +1427 -0
  33. package/dist/engine-BSvSdCSE.d.cts +741 -0
  34. package/dist/engine-BSvSdCSE.d.ts +741 -0
  35. package/dist/index.cjs +426 -6174
  36. package/dist/index.d.cts +299 -1400
  37. package/dist/index.d.ts +299 -1400
  38. package/dist/index.js +413 -6063
  39. package/dist/match/index.cjs +2456 -0
  40. package/dist/match/index.d.cts +253 -0
  41. package/dist/match/index.d.ts +253 -0
  42. package/dist/match/index.js +2456 -0
  43. package/dist/match/remote/index.cjs +21 -0
  44. package/dist/match/remote/index.d.cts +67 -0
  45. package/dist/match/remote/index.d.ts +67 -0
  46. package/dist/match/remote/index.js +21 -0
  47. package/dist/match/voice/index.cjs +23 -0
  48. package/dist/match/voice/index.d.cts +114 -0
  49. package/dist/match/voice/index.d.ts +114 -0
  50. package/dist/match/voice/index.js +23 -0
  51. package/dist/plugin.cjs +52 -71
  52. package/dist/plugin.d.cts +28 -1
  53. package/dist/plugin.d.ts +28 -1
  54. package/dist/plugin.js +26 -23
  55. package/dist/realtime/index.cjs +31 -0
  56. package/dist/realtime/index.d.cts +15 -0
  57. package/dist/realtime/index.d.ts +15 -0
  58. package/dist/realtime/index.js +31 -0
  59. package/dist/types-CrmcX408.d.cts +176 -0
  60. package/dist/types-CrmcX408.d.ts +176 -0
  61. package/dist/types-DiKWjwvN.d.cts +96 -0
  62. package/dist/types-DiKWjwvN.d.ts +96 -0
  63. package/dist/ui/index.cjs +1297 -0
  64. package/dist/ui/index.d.cts +493 -0
  65. package/dist/ui/index.d.ts +493 -0
  66. package/dist/ui/index.js +1297 -0
  67. package/dist/version.cjs +4 -32
  68. package/dist/version.js +2 -1
  69. package/package.json +95 -15
  70. package/src/advanced.ts +49 -0
  71. package/src/auto-resume.tsx +259 -0
  72. package/src/camera-controls.ts +99 -0
  73. package/src/camera-grant-provider.ts +392 -0
  74. package/src/camera-settings.ts +133 -0
  75. package/src/contracts.ts +73 -0
  76. package/src/device-agent/agent.ts +696 -0
  77. package/src/device-agent/beat-policy.ts +46 -0
  78. package/src/device-agent/component.tsx +60 -0
  79. package/src/device-agent/convex-beat-transport.ts +140 -0
  80. package/src/device-agent/convex-command-subscription.ts +101 -0
  81. package/src/device-agent/convex-control-channel.ts +88 -0
  82. package/src/device-agent/http-transport.ts +164 -0
  83. package/src/device-agent/index.ts +26 -0
  84. package/src/device-agent/mqtt-channel.ts +379 -0
  85. package/src/device-agent/mqtt-codec.ts +347 -0
  86. package/src/device-agent/tournament.ts +352 -0
  87. package/src/engine.ts +601 -20
  88. package/src/errors.ts +16 -0
  89. package/src/http-session-provider.ts +21 -183
  90. package/src/index.ts +117 -14
  91. package/src/local-video-quality.ts +43 -0
  92. package/src/match/index.ts +29 -0
  93. package/src/match/provider.tsx +4 -2
  94. package/src/match/remote/hid-gesture.ts +10 -2
  95. package/src/match/remote/types.ts +6 -4
  96. package/src/match/voice/scheduler.ts +6 -1
  97. package/src/match/voice/service.ts +16 -4
  98. package/src/native-runtime.ts +283 -77
  99. package/src/plugin.ts +54 -20
  100. package/src/provider.tsx +129 -22
  101. package/src/realtime/index.ts +56 -0
  102. package/src/resume-policy.ts +85 -0
  103. package/src/resume.ts +132 -0
  104. package/src/rtmp-room-adapter.ts +348 -0
  105. package/src/session-grant.ts +256 -0
  106. package/src/ui/camera-screen.tsx +484 -0
  107. package/src/ui/controls-sheet.tsx +445 -0
  108. package/src/ui/copy.ts +326 -0
  109. package/src/ui/helpers.ts +105 -0
  110. package/src/ui/hooks.ts +59 -0
  111. package/src/ui/index.ts +42 -0
  112. package/src/ui/preview-view.tsx +39 -0
  113. package/src/ui/primitives.tsx +304 -0
  114. package/src/uplink-check.ts +173 -0
  115. package/dist/chunk-VHLUJQG5.js +0 -17
  116. package/src/livekit-room-adapter.ts +0 -353
  117. package/src/livestream-screen.tsx +0 -980
@@ -0,0 +1,696 @@
1
+ import { nextBeatDelayMs, type MqttBeatHealth } from "./beat-policy";
2
+ import type { BootstrapInput } from "../contracts";
3
+ import type { DeviceMetricsSample, LivestreamEngine } from "../engine";
4
+ import { normalizeError } from "../errors";
5
+
6
+ /**
7
+ * DeviceAgent — đăng ký thiết bị với backend khi app mở và giữ heartbeat
8
+ * presence (12s/lần khi foreground) qua proxy. Khi dashboard yêu cầu "Kết nối",
9
+ * heartbeat trả về connect-token → agent mở kênh điều khiển realtime (Convex
10
+ * websocket) để nhận lệnh start/stop livestream tức thì. Publish/unpublish sau
11
+ * khi vào standby vẫn đi đường LiveKit data channel sẵn có của engine.
12
+ */
13
+
14
+ export interface DeviceAgentDeviceInfo {
15
+ appVersion?: string;
16
+ deviceName?: string;
17
+ deviceModel?: string;
18
+ osVersion?: string;
19
+ }
20
+
21
+ export interface DeviceCommand {
22
+ id: string;
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
+ };
36
+ expiresAt: number;
37
+ }
38
+
39
+ export interface DeviceControlSessionGrant {
40
+ id: string;
41
+ token: string;
42
+ convexUrl: string;
43
+ }
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
+
62
+ export interface DeviceHeartbeatBody {
63
+ agentState?: string;
64
+ currentSessionId?: string | null;
65
+ appVersion?: string;
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;
83
+ }
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
+
94
+ export interface DeviceAgentTransport {
95
+ register(
96
+ input: BootstrapInput & DeviceAgentDeviceInfo,
97
+ ): Promise<{ deviceId: string; heartbeatIntervalMs: number }>;
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
+ }>;
106
+ }
107
+
108
+ /** Kênh điều khiển realtime — mặc định là Convex websocket (convex-control-channel). */
109
+ export interface DeviceControlChannel {
110
+ attach(controlSessionId: string, token: string): Promise<void>;
111
+ /** Subscribe lệnh pending — callback gọi lại mỗi khi danh sách đổi. */
112
+ onCommands(
113
+ controlSessionId: string,
114
+ token: string,
115
+ callback: (commands: DeviceCommand[]) => void,
116
+ ): () => void;
117
+ /** Subscribe trạng thái kênh — "ended"/"expired" thì agent tự đóng. */
118
+ onState(
119
+ controlSessionId: string,
120
+ token: string,
121
+ callback: (state: { status: string }) => void,
122
+ ): () => void;
123
+ ack(input: {
124
+ controlSessionId: string;
125
+ token: string;
126
+ commandId: string;
127
+ status: "acked" | "completed" | "failed";
128
+ errorMessage?: string;
129
+ resultSessionId?: string;
130
+ }): Promise<void>;
131
+ close(): void;
132
+ }
133
+
134
+ export interface RemoteStartConfig {
135
+ consentVersion: string;
136
+ /** Tiêu đề mặc định khi dashboard không gửi title. */
137
+ defaultTitle?: string;
138
+ visibility?: "public" | "private";
139
+ metadata?: Record<string, string>;
140
+ }
141
+
142
+ type AppStateListener = (state: "active" | "background" | "inactive") => void;
143
+
144
+ export interface DeviceAgentOptions {
145
+ engine: LivestreamEngine;
146
+ bootstrapInput: BootstrapInput;
147
+ transport: DeviceAgentTransport;
148
+ remoteStart: RemoteStartConfig;
149
+ deviceInfo?: DeviceAgentDeviceInfo;
150
+ onRemoteSessionStarted?: (sessionId: string) => void;
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;
160
+ /** Factory kênh điều khiển — test truyền fake; mặc định Convex websocket. */
161
+ createControlChannel?: (convexUrl: string) => DeviceControlChannel;
162
+ /** Test hook: nguồn app-state; mặc định react-native AppState. */
163
+ subscribeAppState?: (listener: AppStateListener) => () => void;
164
+ }
165
+
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;
187
+ const REGISTER_RETRY_BASE_MS = 2_000;
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;
200
+ /** Chỉ các field server chấp nhận (proxy từ chối key lạ trong metrics). */
201
+ const METRIC_FIELDS = [
202
+ "ramFreeMb",
203
+ "ramTotalMb",
204
+ "storageFreeGb",
205
+ "batteryPct",
206
+ "batteryTempC",
207
+ "thermal",
208
+ "netType",
209
+ "linkMbps",
210
+ "rssi",
211
+ "connQuality",
212
+ "signalPct",
213
+ "localIp",
214
+ ] as const;
215
+
216
+ function defaultSubscribeAppState(listener: AppStateListener): () => void {
217
+ const { AppState } = require("react-native") as typeof import("react-native");
218
+ const subscription = AppState.addEventListener("change", (state) => {
219
+ listener(
220
+ state === "active" ? "active" : state === "background" ? "background" : "inactive",
221
+ );
222
+ });
223
+ return () => subscription.remove();
224
+ }
225
+
226
+ function defaultCreateControlChannel(convexUrl: string): DeviceControlChannel {
227
+ const { createConvexControlChannel } =
228
+ require("./convex-control-channel") as typeof import("./convex-control-channel");
229
+ return createConvexControlChannel(convexUrl);
230
+ }
231
+
232
+ function pickMetrics(
233
+ sample: DeviceMetricsSample | null,
234
+ ): Record<string, number | string> | undefined {
235
+ if (!sample) return undefined;
236
+ const metrics: Record<string, number | string> = {};
237
+ for (const field of METRIC_FIELDS) {
238
+ const value = (sample as Record<string, unknown>)[field];
239
+ if (typeof value === "number" && Number.isFinite(value)) metrics[field] = value;
240
+ else if (typeof value === "string" && value.trim() !== "") metrics[field] = value;
241
+ }
242
+ return Object.keys(metrics).length > 0 ? metrics : undefined;
243
+ }
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
+
274
+ export class DeviceAgent {
275
+ private readonly options: DeviceAgentOptions;
276
+ private running = false;
277
+ private registered = false;
278
+ private foreground = true;
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;
282
+ private timer: ReturnType<typeof setTimeout> | null = null;
283
+ private beatInFlight = false;
284
+ private registerAttempt = 0;
285
+ private unsubscribeAppState: (() => void) | null = null;
286
+
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;
291
+ private controlChannel: DeviceControlChannel | null = null;
292
+ private controlSessionId: string | null = null;
293
+ private controlUnsubscribers: Array<() => void> = [];
294
+ private executing = false;
295
+ private readonly handledCommandIds = new Set<string>();
296
+
297
+ constructor(options: DeviceAgentOptions) {
298
+ this.options = options;
299
+ }
300
+
301
+ start() {
302
+ if (this.running) return;
303
+ this.running = true;
304
+ this.unsubscribeAppState = (
305
+ this.options.subscribeAppState ?? defaultSubscribeAppState
306
+ )((state) => this.handleAppState(state));
307
+ this.schedule(0);
308
+ }
309
+
310
+ stop() {
311
+ this.running = false;
312
+ if (this.timer) clearTimeout(this.timer);
313
+ this.timer = null;
314
+ this.unsubscribeAppState?.();
315
+ this.unsubscribeAppState = null;
316
+ this.closeControlChannel();
317
+ }
318
+
319
+ private handleAppState(state: "active" | "background" | "inactive") {
320
+ const wasForeground = this.foreground;
321
+ this.foreground = state === "active";
322
+ if (!this.running) return;
323
+ if (this.foreground && !wasForeground) {
324
+ // Về foreground: beat ngay để dashboard thấy online lại nhanh
325
+ this.schedule(0);
326
+ }
327
+ if (!this.foreground) {
328
+ if (this.timer) clearTimeout(this.timer);
329
+ this.timer = null;
330
+ // App nền không quay được — đóng kênh, server sẽ end control-session
331
+ this.closeControlChannel();
332
+ }
333
+ }
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
+
367
+ private schedule(delayMs: number) {
368
+ if (!this.running || !this.foreground) return;
369
+ if (this.timer) clearTimeout(this.timer);
370
+ this.timer = setTimeout(() => {
371
+ void this.beat();
372
+ }, delayMs);
373
+ }
374
+
375
+ private async beat() {
376
+ if (!this.running || !this.foreground || this.beatInFlight) return;
377
+ this.beatInFlight = true;
378
+ try {
379
+ if (!this.registered) {
380
+ await this.register();
381
+ if (!this.registered) return; // register() đã schedule retry
382
+ }
383
+ await this.heartbeat();
384
+ this.schedule(this.nextBeatDelay());
385
+ } finally {
386
+ this.beatInFlight = false;
387
+ }
388
+ }
389
+
390
+ private async register() {
391
+ try {
392
+ const result = await this.options.transport.register({
393
+ ...this.options.bootstrapInput,
394
+ ...this.options.deviceInfo,
395
+ });
396
+ if (result.heartbeatIntervalMs > 0) {
397
+ this.heartbeatIntervalMs = result.heartbeatIntervalMs;
398
+ }
399
+ this.registered = true;
400
+ this.registerAttempt = 0;
401
+ } catch {
402
+ // Mạng lỗi/proxy chưa sẵn sàng — backoff rồi thử lại
403
+ const delay = Math.min(
404
+ REGISTER_RETRY_BASE_MS * 2 ** this.registerAttempt,
405
+ REGISTER_RETRY_MAX_MS,
406
+ );
407
+ this.registerAttempt += 1;
408
+ this.schedule(delay);
409
+ }
410
+ }
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
+
430
+ private async heartbeat() {
431
+ const snapshot = this.options.engine.getSnapshot();
432
+ let metrics: Record<string, number | string> | undefined;
433
+ try {
434
+ metrics = pickMetrics((await this.options.deviceMetrics?.()) ?? null);
435
+ } catch {
436
+ metrics = undefined;
437
+ }
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
+ };
452
+ try {
453
+ result = await this.options.transport.heartbeat({
454
+ ...this.options.bootstrapInput,
455
+ agentState: snapshot.state,
456
+ currentSessionId: snapshot.sessionId,
457
+ ...(this.options.deviceInfo?.appVersion
458
+ ? { appVersion: this.options.deviceInfo.appVersion }
459
+ : {}),
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 } : {}),
468
+ });
469
+ } catch {
470
+ // Lỗi liên tiếp (mạng, hoặc server đã xoá device) → thử re-register.
471
+ // Register là upsert idempotent nên gọi lại vô hại.
472
+ this.heartbeatFailures += 1;
473
+ if (this.heartbeatFailures >= 3) {
474
+ this.registered = false;
475
+ this.heartbeatFailures = 0;
476
+ }
477
+ return;
478
+ }
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;
485
+ if (result.controlSession && !this.controlChannel) {
486
+ this.openControlChannel(result.controlSession);
487
+ }
488
+ }
489
+
490
+ private openControlChannel(grant: DeviceControlSessionGrant) {
491
+ let channel: DeviceControlChannel;
492
+ try {
493
+ channel = (this.options.createControlChannel ?? defaultCreateControlChannel)(
494
+ grant.convexUrl,
495
+ );
496
+ } catch {
497
+ return;
498
+ }
499
+ this.controlChannel = channel;
500
+ this.controlSessionId = grant.id;
501
+ void channel
502
+ .attach(grant.id, grant.token)
503
+ .then(() => {
504
+ if (this.controlChannel !== channel) return;
505
+ this.controlUnsubscribers.push(
506
+ channel.onCommands(grant.id, grant.token, (commands) => {
507
+ void this.executeCommands(channel, grant, commands);
508
+ }),
509
+ channel.onState(grant.id, grant.token, (state) => {
510
+ if (state.status === "ended" || state.status === "expired") {
511
+ this.closeControlChannel();
512
+ }
513
+ }),
514
+ );
515
+ })
516
+ .catch(() => {
517
+ if (this.controlChannel === channel) this.closeControlChannel();
518
+ });
519
+ }
520
+
521
+ private closeControlChannel() {
522
+ for (const unsubscribe of this.controlUnsubscribers) {
523
+ try {
524
+ unsubscribe();
525
+ } catch {
526
+ /* kênh đã đóng */
527
+ }
528
+ }
529
+ this.controlUnsubscribers = [];
530
+ try {
531
+ this.controlChannel?.close();
532
+ } catch {
533
+ /* kênh đã đóng */
534
+ }
535
+ this.controlChannel = null;
536
+ this.controlSessionId = null;
537
+ this.handledCommandIds.clear();
538
+ }
539
+
540
+ private async executeCommands(
541
+ channel: DeviceControlChannel,
542
+ grant: DeviceControlSessionGrant,
543
+ commands: DeviceCommand[],
544
+ ) {
545
+ for (const command of commands) {
546
+ if (this.controlChannel !== channel) return;
547
+ if (this.handledCommandIds.has(command.id)) continue;
548
+ this.handledCommandIds.add(command.id);
549
+ if (this.executing) {
550
+ // Single-flight: server cũng dedupe, nhưng phòng lệnh dồn trong 1 batch
551
+ await this.safeAck(channel, grant, command.id, "failed", "Thiết bị đang bận");
552
+ continue;
553
+ }
554
+ this.executing = true;
555
+ try {
556
+ await this.executeCommand(channel, grant, command);
557
+ } finally {
558
+ this.executing = false;
559
+ }
560
+ }
561
+ }
562
+
563
+ private async executeCommand(
564
+ channel: DeviceControlChannel,
565
+ grant: DeviceControlSessionGrant,
566
+ command: DeviceCommand,
567
+ ) {
568
+ if (command.expiresAt <= Date.now()) {
569
+ await this.safeAck(channel, grant, command.id, "failed", "Lệnh đã hết hạn");
570
+ return;
571
+ }
572
+ await this.safeAck(channel, grant, command.id, "acked");
573
+ try {
574
+ if (command.type === "start_livestream") {
575
+ await this.runStartLivestream(command);
576
+ const sessionId = this.options.engine.getSnapshot().sessionId;
577
+ await this.safeAck(
578
+ channel,
579
+ grant,
580
+ command.id,
581
+ "completed",
582
+ undefined,
583
+ sessionId ?? undefined,
584
+ );
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");
592
+ } else {
593
+ await this.runStopLivestream();
594
+ await this.safeAck(channel, grant, command.id, "completed");
595
+ }
596
+ } catch (error) {
597
+ await this.safeAck(
598
+ channel,
599
+ grant,
600
+ command.id,
601
+ "failed",
602
+ normalizeError(error).message,
603
+ );
604
+ }
605
+ }
606
+
607
+ private async runStartLivestream(command: DeviceCommand) {
608
+ const engine = this.options.engine;
609
+ const state = engine.getSnapshot().state;
610
+ if (state !== "idle" && state !== "ended" && state !== "preview") {
611
+ throw new Error(`Thiết bị không sẵn sàng (trạng thái ${state})`);
612
+ }
613
+ if (state !== "preview") {
614
+ await engine.prepare();
615
+ }
616
+ const remote = this.options.remoteStart;
617
+ const courtRef = command.payload.courtRef?.trim();
618
+ await engine.start(
619
+ {
620
+ externalSessionId: `remote-${command.id}`,
621
+ title: command.payload.title?.trim() || remote.defaultTitle || "Livestream",
622
+ visibility: command.payload.visibility ?? remote.visibility ?? "public",
623
+ consentVersion: remote.consentVersion,
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
+ },
630
+ standby: true,
631
+ },
632
+ { autoPublish: false },
633
+ );
634
+ }
635
+
636
+ private async runStopLivestream() {
637
+ const engine = this.options.engine;
638
+ if (engine.getSnapshot().sessionId) {
639
+ await engine.stop("user");
640
+ }
641
+ }
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
+
675
+ private async safeAck(
676
+ channel: DeviceControlChannel,
677
+ grant: DeviceControlSessionGrant,
678
+ commandId: string,
679
+ status: "acked" | "completed" | "failed",
680
+ errorMessage?: string,
681
+ resultSessionId?: string,
682
+ ) {
683
+ try {
684
+ await channel.ack({
685
+ controlSessionId: grant.id,
686
+ token: grant.token,
687
+ commandId,
688
+ status,
689
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
690
+ ...(resultSessionId !== undefined ? { resultSessionId } : {}),
691
+ });
692
+ } catch {
693
+ /* mất kênh giữa chừng — server cron sẽ expire lệnh */
694
+ }
695
+ }
696
+ }