@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.
- package/CHANGELOG.md +65 -0
- package/README.md +163 -40
- package/dist/advanced-DcbHBCGb.d.ts +300 -0
- package/dist/advanced-DeQwg7MK.d.cts +300 -0
- package/dist/advanced.cjs +47 -0
- package/dist/advanced.d.cts +3 -0
- package/dist/advanced.d.ts +3 -0
- package/dist/advanced.js +47 -0
- package/dist/camera-controls-By8tHigY.d.cts +97 -0
- package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
- package/dist/chunk-2JHULJZ7.js +265 -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-7OX2DSKI.cjs +33 -0
- package/dist/chunk-7Z7TN5N3.js +318 -0
- package/dist/chunk-DHDX3ZOO.js +800 -0
- package/dist/chunk-GXQYMPHE.cjs +9 -0
- package/dist/chunk-IUIRVKJI.cjs +2290 -0
- package/dist/chunk-KFQGP6VL.js +33 -0
- package/dist/chunk-LCWGNGEN.cjs +265 -0
- package/dist/chunk-TBUEXRFQ.cjs +318 -0
- package/dist/chunk-XGP2MN5R.js +2290 -0
- package/dist/chunk-XL4CISVQ.js +9 -0
- package/dist/chunk-YGSQ25UA.cjs +800 -0
- 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 +1427 -0
- package/dist/device-agent/index.d.cts +493 -0
- package/dist/device-agent/index.d.ts +493 -0
- package/dist/device-agent/index.js +1427 -0
- package/dist/engine-BSvSdCSE.d.cts +741 -0
- package/dist/engine-BSvSdCSE.d.ts +741 -0
- package/dist/index.cjs +426 -6174
- package/dist/index.d.cts +299 -1400
- package/dist/index.d.ts +299 -1400
- package/dist/index.js +413 -6063
- package/dist/match/index.cjs +2456 -0
- package/dist/match/index.d.cts +253 -0
- package/dist/match/index.d.ts +253 -0
- package/dist/match/index.js +2456 -0
- package/dist/match/remote/index.cjs +21 -0
- package/dist/match/remote/index.d.cts +67 -0
- package/dist/match/remote/index.d.ts +67 -0
- package/dist/match/remote/index.js +21 -0
- package/dist/match/voice/index.cjs +23 -0
- package/dist/match/voice/index.d.cts +114 -0
- package/dist/match/voice/index.d.ts +114 -0
- package/dist/match/voice/index.js +23 -0
- package/dist/plugin.cjs +52 -71
- package/dist/plugin.d.cts +28 -1
- package/dist/plugin.d.ts +28 -1
- package/dist/plugin.js +26 -23
- 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/types-CrmcX408.d.cts +176 -0
- package/dist/types-CrmcX408.d.ts +176 -0
- package/dist/types-DiKWjwvN.d.cts +96 -0
- package/dist/types-DiKWjwvN.d.ts +96 -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 +4 -32
- package/dist/version.js +2 -1
- package/package.json +95 -15
- package/src/advanced.ts +49 -0
- 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 +696 -0
- package/src/device-agent/beat-policy.ts +46 -0
- package/src/device-agent/component.tsx +60 -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 +88 -0
- package/src/device-agent/http-transport.ts +164 -0
- package/src/device-agent/index.ts +26 -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 +601 -20
- package/src/errors.ts +16 -0
- package/src/http-session-provider.ts +21 -183
- package/src/index.ts +117 -14
- package/src/local-video-quality.ts +43 -0
- package/src/match/index.ts +29 -0
- package/src/match/provider.tsx +4 -2
- package/src/match/remote/hid-gesture.ts +10 -2
- package/src/match/remote/types.ts +6 -4
- package/src/match/voice/scheduler.ts +6 -1
- package/src/match/voice/service.ts +16 -4
- package/src/native-runtime.ts +283 -77
- package/src/plugin.ts +54 -20
- package/src/provider.tsx +129 -22
- 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 +348 -0
- 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/chunk-VHLUJQG5.js +0 -17
- package/src/livekit-room-adapter.ts +0 -353
- 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
|
+
}
|