@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,4 +1,37 @@
1
- import { Q as LivestreamEngine, B as BootstrapInput, D as DeviceMetricsSample } from '../engine-CCSSqPbZ.cjs';
1
+ import { a0 as LivestreamEngine, B as BootstrapInput, D as DeviceMetricsSample } from '../engine-BSvSdCSE.cjs';
2
+ import { C as ConvexDeviceRealtime } from '../convex-command-subscription-DUgpu75l.cjs';
3
+ export { c as createConvexCommandSubscription, a as createConvexDeviceRealtime } from '../convex-command-subscription-DUgpu75l.cjs';
4
+
5
+ /**
6
+ * Nhịp heartbeat thích ứng — bản CHÉP của luật trong
7
+ * `packages/shared/src/realtime.ts`.
8
+ *
9
+ * Vì sao chép chứ không import: gói expo-sdk cố ý KHÔNG phụ thuộc
10
+ * `@pickleball/shared` (guard `scripts/verify-pack.mjs` chặn), để app đối tác
11
+ * cài SDK không phải kéo theo cả gói dùng chung của backend. Đổi luật ở đây thì
12
+ * 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.
13
+ */
14
+ declare const BEAT_SLOW_MS = 60000;
15
+ declare const BEAT_FAST_MS = 12000;
16
+ interface MqttBeatHealth {
17
+ connected: boolean;
18
+ subscribed: boolean;
19
+ lastInboundAt: number;
20
+ healthySince: number;
21
+ }
22
+ /** Đủ CẢ BỐN vế mới được coi là kênh khoẻ. */
23
+ declare function mqttBeatHealthy(h: MqttBeatHealth, now: number): boolean;
24
+ /**
25
+ * Nhịp kế tiếp = min(luật nội bộ, hint của server), và THIẾU hint thì mặc định
26
+ * nhanh. Nhờ vậy máy chưa nhận được hint (server cũ, hoặc beat lỗi) không bao
27
+ * giờ tự ý giãn nhịp, còn tắt rollout chỉ là đổi một biến env phía server.
28
+ */
29
+ declare function nextBeatDelayMs(input: {
30
+ health: MqttBeatHealth | null;
31
+ now: number;
32
+ serverHintMs?: number;
33
+ busy?: boolean;
34
+ }): number;
2
35
 
3
36
  /**
4
37
  * DeviceAgent — đăng ký thiết bị với backend khi app mở và giữ heartbeat
@@ -15,10 +48,12 @@ interface DeviceAgentDeviceInfo {
15
48
  }
16
49
  interface DeviceCommand {
17
50
  id: string;
18
- type: "start_livestream" | "stop_livestream";
51
+ type: "start_livestream" | "stop_livestream" | "publish_livestream" | "unpublish_livestream";
19
52
  payload: {
20
53
  title?: string;
21
54
  visibility?: "public" | "private";
55
+ /** Arm-từ-xa chế độ giải đấu: phiên standby sẽ đậu vào sân này. */
56
+ courtRef?: string;
22
57
  };
23
58
  expiresAt: number;
24
59
  }
@@ -27,12 +62,49 @@ interface DeviceControlSessionGrant {
27
62
  token: string;
28
63
  convexUrl: string;
29
64
  }
65
+ /**
66
+ * Một lần tiến trình app chết trước đó — Android `ApplicationExitInfo`, native
67
+ * đã quy đổi `reasonName` ("PACKAGE_UPDATED", "CRASH", "ANR", "OTHER"…). Xem
68
+ * NativeProcessExit của @pickleball/rtmp-native; iOS không có nguồn tương đương.
69
+ */
70
+ interface DeviceProcessExit {
71
+ /** epoch ms lúc chết. */
72
+ at: number;
73
+ reason: number;
74
+ reasonName: string;
75
+ /** Chuỗi hệ thống, VD "stop com.google.android.webview due to installPackageLI". */
76
+ description?: string;
77
+ /** IMPORTANCE_* lúc chết — 100 = đang ở tiền cảnh. */
78
+ importance?: number;
79
+ pid?: number;
80
+ }
30
81
  interface DeviceHeartbeatBody {
31
82
  agentState?: string;
32
83
  currentSessionId?: string | null;
33
84
  appVersion?: string;
34
85
  metrics?: Record<string, number | string>;
86
+ /**
87
+ * 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
88
+ * đầu sau khi mở (server giữ bản mới nhất, ghi idempotent theo mốc).
89
+ */
90
+ recentExits?: DeviceProcessExit[];
91
+ /**
92
+ * Trạng thái nguồn app tự khai: "rest" = chế độ nghỉ (camera/service đã tắt,
93
+ * máy đang nguội). Server chỉ ghi khi đổi — dashboard hiện badge "Đang nghỉ".
94
+ */
95
+ powerState?: "rest" | "active";
96
+ /**
97
+ * Xin tỉ số kèm response beat — CHỈ bật khi kênh MQTT đứt (đường nhanh
98
+ * plv1/dev/{id}/score câm); MQTT khoẻ mà vẫn xin là server đọc doc tỉ số
99
+ * mỗi nhịp vô ích.
100
+ */
101
+ wantScore?: boolean;
35
102
  }
103
+ /**
104
+ * Field app tự gắn thêm vào MỖI heartbeat. Hàm được gọi ngay trước khi gửi,
105
+ * trả undefined/{} khi không có gì — ném lỗi thì bị nuốt, heartbeat vẫn đi.
106
+ */
107
+ type DeviceHeartbeatExtras = Pick<DeviceHeartbeatBody, "recentExits" | "powerState" | "wantScore">;
36
108
  interface DeviceAgentTransport {
37
109
  register(input: BootstrapInput & DeviceAgentDeviceInfo): Promise<{
38
110
  deviceId: string;
@@ -40,6 +112,11 @@ interface DeviceAgentTransport {
40
112
  }>;
41
113
  heartbeat(input: BootstrapInput & DeviceHeartbeatBody): Promise<{
42
114
  controlSession: DeviceControlSessionGrant | null;
115
+ /**
116
+ * Nhịp server gợi ý cho lượt beat kế (adaptive heartbeat). Optional để
117
+ * transport/server đời cũ vẫn hợp lệ — thiếu thì agent giữ nhịp nhanh.
118
+ */
119
+ beatIntervalMs?: number;
43
120
  }>;
44
121
  }
45
122
  /** Kênh điều khiển realtime — mặc định là Convex websocket (convex-control-channel). */
@@ -77,22 +154,42 @@ interface DeviceAgentOptions {
77
154
  deviceInfo?: DeviceAgentDeviceInfo;
78
155
  onRemoteSessionStarted?: (sessionId: string) => void;
79
156
  deviceMetrics?: () => Promise<DeviceMetricsSample | null>;
157
+ /** Field bổ sung cho heartbeat (VD recentExits) — xem DeviceHeartbeatExtras. */
158
+ heartbeatExtras?: () => DeviceHeartbeatExtras | undefined;
159
+ /**
160
+ * Sức khoẻ kênh MQTT của app (kênh nằm ở tầng app, không thuộc SDK). Có
161
+ * nguồn này VÀ server bật rollout thì nhịp beat giãn 12s → 60s; thiếu nó thì
162
+ * agent chạy y hệt trước đây. Ném lỗi trong callback được coi như "không có".
163
+ */
164
+ mqttHealth?: () => MqttBeatHealth | null;
80
165
  /** Factory kênh điều khiển — test truyền fake; mặc định Convex websocket. */
81
166
  createControlChannel?: (convexUrl: string) => DeviceControlChannel;
82
167
  /** Test hook: nguồn app-state; mặc định react-native AppState. */
83
168
  subscribeAppState?: (listener: AppStateListener) => () => void;
84
169
  }
170
+ /**
171
+ * Metrics lần này có đáng gửi so với lần đã gửi không. Hàm thuần để test dễ.
172
+ * Đá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
173
+ * <10%); pin đổi ≥5% so với mốc đã gửi; đổi mức nhiệt (thermal) hoặc loại mạng.
174
+ * KHÔNG tính RAM/sóng/nhiệt-độ-pin: chúng nhích liên tục, để mốc 5 phút lo.
175
+ */
176
+ declare function metricsCrossedThreshold(last: Record<string, number | string> | undefined, next: Record<string, number | string>): boolean;
85
177
  declare class DeviceAgent {
86
178
  private readonly options;
87
179
  private running;
88
180
  private registered;
89
181
  private foreground;
90
182
  private heartbeatIntervalMs;
183
+ /** Nhịp server gợi ý ở beat gần nhất — undefined = server chưa nói gì → 12s. */
184
+ private serverBeatHintMs;
91
185
  private timer;
92
186
  private beatInFlight;
93
187
  private registerAttempt;
94
188
  private unsubscribeAppState;
95
189
  private heartbeatFailures;
190
+ /** Metrics đã gửi lần gần nhất + mốc thời gian, để lọc "chỉ gửi khi đổi". */
191
+ private lastSentMetrics?;
192
+ private lastMetricsSentAt;
96
193
  private controlChannel;
97
194
  private controlSessionId;
98
195
  private controlUnsubscribers;
@@ -102,9 +199,20 @@ declare class DeviceAgent {
102
199
  start(): void;
103
200
  stop(): void;
104
201
  private handleAppState;
202
+ /**
203
+ * Đang có phiên hoặc đã mở kênh điều khiển = lệnh tới qua kênh, nhịp thường
204
+ * là đủ. Rảnh và chưa có kênh = đang chờ lệnh, đập nhanh cho kịp dashboard.
205
+ */
206
+ private nextBeatDelay;
105
207
  private schedule;
106
208
  private beat;
107
209
  private register;
210
+ /**
211
+ * Lọc metrics cho heartbeat: chỉ trả (và ghi nhớ) khi vượt ngưỡng
212
+ * (metricsCrossedThreshold) hoặc đã quá METRICS_BASELINE_MS kể từ lần gửi.
213
+ * Trả undefined = nhịp này không kèm metrics.
214
+ */
215
+ private selectMetricsForHeartbeat;
108
216
  private heartbeat;
109
217
  private openControlChannel;
110
218
  private closeControlChannel;
@@ -112,6 +220,13 @@ declare class DeviceAgent {
112
220
  private executeCommand;
113
221
  private runStartLivestream;
114
222
  private runStopLivestream;
223
+ /**
224
+ * standby → live theo lệnh dashboard. startPublishing() NUỐT lỗi (recordError
225
+ * vào snapshot thay vì throw) nên phải kiểm state sau khi gọi — không kiểm là
226
+ * lệnh được ack "completed" dù thiết bị chưa hề phát.
227
+ */
228
+ private runPublish;
229
+ private runUnpublish;
115
230
  private safeAck;
116
231
  }
117
232
 
@@ -127,10 +242,125 @@ interface HttpDeviceAgentTransportOptions {
127
242
  register: string;
128
243
  heartbeat: string;
129
244
  }>;
245
+ /**
246
+ * Nhận envelope `data` thô sau khi transport validate xong. Validator phía
247
+ * dưới drop field ngoài contract (deviceToken/pairing/claimed/ownerName của
248
+ * đường first-party) — app device-streamer đọc chúng qua hook này; contract
249
+ * partner cũ không đổi.
250
+ */
251
+ onData?: (endpoint: "register" | "heartbeat", data: Record<string, unknown>) => void;
130
252
  }
131
253
  /** Transport HTTP cho DeviceAgent — mirror createHttpSessionProvider. */
132
254
  declare function createHttpDeviceAgentTransport(options: HttpDeviceAgentTransportOptions): DeviceAgentTransport;
133
255
 
256
+ /**
257
+ * TournamentController — "chế độ giải đấu" cho điện thoại camera đặt tại sân.
258
+ *
259
+ * Vận hành arm máy MỘT lần khi setup giải: controller giữ đúng một phiên
260
+ * standby gắn sân (metadata.courtRef) đậu trong phòng LiveKit cả ngày ($0
261
+ * egress). Backend đậu phiên vào bảng courts (bindStandbySession) để REST
262
+ * match-start của đối tác tiêu thụ: trọng tài bấm start → server claim phiên +
263
+ * flip live + lệnh "start" qua data channel — không ai chạm điện thoại.
264
+ *
265
+ * Khi phiên kết thúc VÌ BẤT KỲ LÝ DO GÌ (match-end, standby_timeout 15 phút,
266
+ * watchdog, rớt mạng, app khởi động lại) controller tự re-arm phiên standby
267
+ * mới cho cùng sân sau vài giây — một state machine duy nhất xử lý trận nối
268
+ * trận, reboot và hồi phục lỗi.
269
+ */
270
+
271
+ /** Góc nhìn tối thiểu lên engine — test truyền fake object thường. */
272
+ interface TournamentEngineView {
273
+ getSnapshot(): {
274
+ state: string;
275
+ sessionId: string | null;
276
+ };
277
+ subscribe(listener: () => void): () => void;
278
+ prepare(): Promise<void>;
279
+ start(input: {
280
+ externalSessionId: string;
281
+ title: string;
282
+ visibility?: "public" | "private";
283
+ consentVersion: string;
284
+ metadata?: Record<string, string>;
285
+ }, options?: {
286
+ autoPublish?: boolean;
287
+ }): Promise<void>;
288
+ stop(reason: "user" | "error"): Promise<void>;
289
+ }
290
+ interface TournamentPersistedState {
291
+ courtRef: string;
292
+ }
293
+ /** Persist trạng thái armed (AsyncStorage/SQLite tuỳ app) — reboot tự re-arm. */
294
+ interface TournamentStorage {
295
+ load(): Promise<TournamentPersistedState | null>;
296
+ save(state: TournamentPersistedState | null): Promise<void>;
297
+ }
298
+ type TournamentPhase = "disarmed" | "arming" | "standby" | "live" | "retry_wait";
299
+ interface TournamentState {
300
+ armed: boolean;
301
+ courtRef: string | null;
302
+ phase: TournamentPhase;
303
+ lastError: string | null;
304
+ }
305
+ interface TournamentStandbyStart {
306
+ consentVersion: string;
307
+ /** Tiêu đề phiên chờ theo sân — mặc định "{courtRef} — chờ trận". */
308
+ titleFor?: (courtRef: string) => string;
309
+ visibility?: "public" | "private";
310
+ metadata?: Record<string, string>;
311
+ }
312
+ interface TournamentControllerOptions {
313
+ engine: TournamentEngineView | LivestreamEngine;
314
+ standbyStart: TournamentStandbyStart;
315
+ storage?: TournamentStorage;
316
+ onStateChange?: (state: TournamentState) => void;
317
+ /** Backoff re-arm khi lỗi liên tiếp (test truyền giá trị nhỏ). */
318
+ rearmBaseDelayMs?: number;
319
+ rearmMaxDelayMs?: number;
320
+ /**
321
+ * Thời gian NHƯỜNG cho engine tự cứu phiên khi nó ở "error" mà còn sessionId.
322
+ * Phải dài hơn reconnectTimeoutMs của engine (mặc định 30s) để engine kịp
323
+ * hoặc hồi, hoặc tự cắt bằng network_timeout, trước khi controller can thiệp.
324
+ */
325
+ recoveryGraceMs?: number;
326
+ now?: () => number;
327
+ }
328
+ declare class TournamentController {
329
+ private readonly options;
330
+ private readonly engine;
331
+ private armed;
332
+ private courtRef;
333
+ private lastError;
334
+ private attemptInFlight;
335
+ private retryAttempt;
336
+ private retryTimer;
337
+ /** Mốc hết nhường cho engine tự cứu phiên; null = đang không trong pha đó. */
338
+ private recoveryDeadline;
339
+ private unsubscribe;
340
+ private disposed;
341
+ /** Tăng mỗi lần arm/disarm — huỷ attempt cũ đang chạy dở. */
342
+ private armGeneration;
343
+ constructor(options: TournamentControllerOptions);
344
+ getState(): TournamentState;
345
+ /** Khôi phục sau khi app mở lại (reboot giữa giải) — gọi sau engine.initialize(). */
346
+ restore(): Promise<void>;
347
+ arm(courtRef: string): Promise<void>;
348
+ /**
349
+ * Tắt chế độ giải đấu. Phiên standby đang chờ được nhả (stop); phiên đang
350
+ * LIVE là trận do đối tác điều khiển — không cắt, chỉ ngừng re-arm.
351
+ */
352
+ disarm(): Promise<void>;
353
+ dispose(): void;
354
+ private emit;
355
+ private clearRetry;
356
+ /** Engine còn đang tự cứu CHÍNH phiên này → nhường, đừng cắt để arm phiên mới. */
357
+ private isEngineRecovering;
358
+ private scheduleRecoveryCheck;
359
+ private handleEngineChange;
360
+ private scheduleRearm;
361
+ private ensureStandby;
362
+ }
363
+
134
364
  /**
135
365
  * Kênh điều khiển realtime mặc định: Convex websocket subscribe các function
136
366
  * deviceControl (auth bằng connect-token nhận qua heartbeat — thiết bị không
@@ -139,6 +369,21 @@ declare function createHttpDeviceAgentTransport(options: HttpDeviceAgentTranspor
139
369
  */
140
370
  declare function createConvexControlChannel(convexUrl: string): DeviceControlChannel;
141
371
 
372
+ interface ConvexBeatOptions {
373
+ /** deviceToken hiện tại (đọc mỗi nhịp — token có thể vừa được cấp/đổi). */
374
+ getDeviceToken: () => string | null | undefined;
375
+ /**
376
+ * Cùng hook với createHttpDeviceAgentTransport.onData: đường WS phải đổ
377
+ * envelope y hệt để handleTransportData (claim/pairing/assignedCourt/
378
+ * activeSession/preview) không phân biệt beat đến từ đường nào.
379
+ */
380
+ onData?: (endpoint: "heartbeat", data: Record<string, unknown>) => void;
381
+ timeoutMs?: number;
382
+ /** Test hook. */
383
+ now?: () => number;
384
+ }
385
+ declare function withConvexBeat(base: DeviceAgentTransport, realtime: ConvexDeviceRealtime, options: ConvexBeatOptions): DeviceAgentTransport;
386
+
142
387
  /**
143
388
  * Bật device-agent: đăng ký thiết bị + heartbeat presence khi app mở, nhận
144
389
  * lệnh start/stop livestream từ dashboard qua kênh điều khiển on-demand.
@@ -151,7 +396,98 @@ interface PickleballDeviceAgentProps {
151
396
  deviceInfo?: DeviceAgentDeviceInfo;
152
397
  onRemoteSessionStarted?: (sessionId: string) => void;
153
398
  createControlChannel?: (convexUrl: string) => DeviceControlChannel;
399
+ /** Field bổ sung cho heartbeat (VD recentExits) — đọc qua ref nên đổi hàm không dựng lại agent. */
400
+ heartbeatExtras?: () => DeviceHeartbeatExtras | undefined;
401
+ /**
402
+ * Sức khoẻ kênh MQTT của app — nguồn để agent giãn nhịp beat khi kênh khoẻ
403
+ * VÀ server bật rollout. Bỏ trống = agent giữ nhịp cũ.
404
+ */
405
+ mqttHealth?: () => MqttBeatHealth | null;
154
406
  }
155
407
  declare function PickleballDeviceAgent(props: PickleballDeviceAgentProps): null;
156
408
 
157
- export { DeviceAgent, type DeviceAgentDeviceInfo, type DeviceAgentOptions, type DeviceAgentTransport, type DeviceCommand, type DeviceControlChannel, type DeviceControlSessionGrant, type DeviceHeartbeatBody, type HttpDeviceAgentTransportOptions, PickleballDeviceAgent, type PickleballDeviceAgentProps, type RemoteStartConfig, createConvexControlChannel, createHttpDeviceAgentTransport };
409
+ /**
410
+ * Kênh MQTT của máy camera — vòng đời kết nối trên WebSocket.
411
+ *
412
+ * Vai trò trong kiến trúc: đây là "device plane". Presence do broker phát hiện
413
+ * ở tầng TCP (LWT) chứ không phải do server đoán từ nhịp heartbeat, nên máy
414
+ * chết được biết trong ~45s, và biết TỨC THÌ khi app đóng kết nối sạch. Lệnh
415
+ * start/stop cũng đi đường này; Convex websocket + poll 30s vẫn giữ nguyên làm
416
+ * lưới an toàn (broker sập không được giết match-start).
417
+ *
418
+ * Module này KHÔNG import `@pickleball/shared`: hợp đồng topic/ngưỡng do phía
419
+ * app truyền vào. Lý do là gói SDK này được publish ra npm và Metro đọc thẳng
420
+ * `src/`, nên một import workspace ở đây sẽ vỡ khi app ngoài repo cài gói.
421
+ * Nguồn duy nhất của hợp đồng vẫn là `packages/shared/src/realtime.ts` — chỉ
422
+ * khác là chỗ nối dây nằm ở app first-party.
423
+ */
424
+ /** Bề mặt WebSocket tối thiểu mà kênh cần — đủ để test thay bằng đồ giả. */
425
+ interface MqttSocket {
426
+ binaryType: string;
427
+ send(data: ArrayBufferView | ArrayBuffer): void;
428
+ close(): void;
429
+ onopen: (() => void) | null;
430
+ onmessage: ((event: {
431
+ data: unknown;
432
+ }) => void) | null;
433
+ onerror: ((event: unknown) => void) | null;
434
+ onclose: ((event: {
435
+ code?: number;
436
+ }) => void) | null;
437
+ }
438
+ interface MqttChannelOptions {
439
+ /** wss://mqtt.cabinvn.com/mqtt */
440
+ url: string;
441
+ deviceId: string;
442
+ /** Token bền của máy — broker chuyển thẳng cho auth-hook Convex. */
443
+ deviceToken: string;
444
+ topics: {
445
+ status: string;
446
+ cmd: string;
447
+ cfg: string;
448
+ evt: string;
449
+ score: string;
450
+ };
451
+ /** Payload retained lúc kết nối + payload LWT lúc rớt. */
452
+ statusPayload: (online: boolean) => string;
453
+ keepaliveS: number;
454
+ /** Thang backoff (ms); phần tử cuối được lặp lại mãi. */
455
+ reconnectDelaysMs: readonly number[];
456
+ onMessage?: (topic: string, payload: string) => void;
457
+ onConnectionChange?: (connected: boolean) => void;
458
+ /** Auth bị từ chối (token thu hồi / máy chưa claim) — để app log/khai báo. */
459
+ onAuthRejected?: (reason: string) => void;
460
+ }
461
+ interface MqttChannelDeps {
462
+ createSocket?: (url: string, protocols: string[]) => MqttSocket;
463
+ setTimeout?: (fn: () => void, ms: number) => unknown;
464
+ clearTimeout?: (handle: unknown) => void;
465
+ now?: () => number;
466
+ /** Jitter ±20% chống bão reconnect đồng loạt sau khi broker restart. */
467
+ random?: () => number;
468
+ }
469
+ /**
470
+ * Sức khoẻ kênh cho quyết định giãn nhịp beat. `isConnected()` KHÔNG đủ: nối
471
+ * được mà chưa SUBACK thì máy không nghe được lệnh nào, còn TCP mở mà broker
472
+ * chết thì `connected` vẫn true tới tận keepalive kế.
473
+ */
474
+ interface MqttChannelHealth {
475
+ connected: boolean;
476
+ subscribed: boolean;
477
+ lastInboundAt: number;
478
+ healthySince: number;
479
+ }
480
+ interface MqttChannel {
481
+ start(): void;
482
+ /** Đóng CHỦ ĐỘNG: gửi DISCONNECT nên broker KHÔNG bắn LWT. */
483
+ stop(): void;
484
+ isConnected(): boolean;
485
+ health(): MqttChannelHealth;
486
+ publish(topic: string, payload: string, opts?: {
487
+ qos?: 0 | 1;
488
+ retain?: boolean;
489
+ }): boolean;
490
+ }
491
+ declare function createMqttChannel(o: MqttChannelOptions, deps?: MqttChannelDeps): MqttChannel;
492
+
493
+ export { BEAT_FAST_MS, BEAT_SLOW_MS, type ConvexBeatOptions, ConvexDeviceRealtime, DeviceAgent, type DeviceAgentDeviceInfo, type DeviceAgentOptions, type DeviceAgentTransport, type DeviceCommand, type DeviceControlChannel, type DeviceControlSessionGrant, type DeviceHeartbeatBody, type DeviceHeartbeatExtras, type DeviceProcessExit, type HttpDeviceAgentTransportOptions, type MqttBeatHealth, type MqttChannel, type MqttChannelDeps, type MqttChannelHealth, type MqttChannelOptions, type MqttSocket, PickleballDeviceAgent, type PickleballDeviceAgentProps, type RemoteStartConfig, TournamentController, type TournamentControllerOptions, type TournamentEngineView, type TournamentPersistedState, type TournamentPhase, type TournamentStandbyStart, type TournamentState, type TournamentStorage, createConvexControlChannel, createHttpDeviceAgentTransport, createMqttChannel, metricsCrossedThreshold, mqttBeatHealthy, nextBeatDelayMs, withConvexBeat };