@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.
- package/CHANGELOG.md +41 -0
- package/README.md +155 -101
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +14 -2
- package/dist/advanced.d.cts +2 -2
- package/dist/advanced.d.ts +2 -2
- package/dist/advanced.js +17 -5
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-62YV3PCD.cjs +139 -0
- package/dist/chunk-6F3HN6US.js +50 -0
- package/dist/chunk-6YE5BL3A.cjs +50 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
- package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
- package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
- package/dist/chunk-ZSKSXOCZ.js +139 -0
- package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
- package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
- package/dist/device-agent/index.cjs +973 -44
- package/dist/device-agent/index.d.cts +339 -3
- package/dist/device-agent/index.d.ts +339 -3
- package/dist/device-agent/index.js +940 -11
- package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
- package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
- package/dist/index.cjs +419 -937
- package/dist/index.d.cts +315 -32
- package/dist/index.d.ts +315 -32
- package/dist/index.js +423 -941
- package/dist/plugin.cjs +11 -13
- package/dist/plugin.d.cts +15 -1
- package/dist/plugin.d.ts +15 -1
- package/dist/plugin.js +7 -9
- package/dist/realtime/index.cjs +31 -0
- package/dist/realtime/index.d.cts +15 -0
- package/dist/realtime/index.d.ts +15 -0
- package/dist/realtime/index.js +31 -0
- package/dist/ui/index.cjs +1297 -0
- package/dist/ui/index.d.cts +493 -0
- package/dist/ui/index.d.ts +493 -0
- package/dist/ui/index.js +1297 -0
- package/dist/version.cjs +2 -2
- package/dist/version.js +1 -1
- package/package.json +31 -15
- package/src/advanced.ts +11 -9
- package/src/auto-resume.tsx +259 -0
- package/src/camera-controls.ts +99 -0
- package/src/camera-grant-provider.ts +392 -0
- package/src/camera-settings.ts +133 -0
- package/src/contracts.ts +73 -0
- package/src/device-agent/agent.ts +262 -9
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +11 -0
- package/src/device-agent/convex-beat-transport.ts +140 -0
- package/src/device-agent/convex-command-subscription.ts +101 -0
- package/src/device-agent/convex-control-channel.ts +7 -1
- package/src/device-agent/http-transport.ts +22 -1
- package/src/device-agent/index.ts +22 -0
- package/src/device-agent/mqtt-channel.ts +379 -0
- package/src/device-agent/mqtt-codec.ts +347 -0
- package/src/device-agent/tournament.ts +352 -0
- package/src/engine.ts +580 -15
- package/src/http-session-provider.ts +21 -189
- package/src/index.ts +84 -5
- package/src/local-video-quality.ts +43 -0
- package/src/native-runtime.ts +234 -78
- package/src/plugin.ts +19 -6
- package/src/provider.tsx +94 -18
- package/src/realtime/index.ts +56 -0
- package/src/resume-policy.ts +85 -0
- package/src/resume.ts +132 -0
- package/src/rtmp-room-adapter.ts +164 -12
- package/src/session-grant.ts +256 -0
- package/src/ui/camera-screen.tsx +484 -0
- package/src/ui/controls-sheet.tsx +445 -0
- package/src/ui/copy.ts +326 -0
- package/src/ui/helpers.ts +105 -0
- package/src/ui/hooks.ts +59 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/preview-view.tsx +39 -0
- package/src/ui/primitives.tsx +304 -0
- package/src/uplink-check.ts +173 -0
- package/dist/advanced-BXDrDl1h.d.ts +0 -316
- package/dist/advanced-BdAa2Erv.d.cts +0 -316
- package/src/livekit-room-adapter.ts +0 -390
- 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:
|
|
23
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
...(
|
|
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: {
|
|
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
|
+
}
|