@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,379 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONNACK_REASON,
|
|
3
|
+
decodePackets,
|
|
4
|
+
encodeConnect,
|
|
5
|
+
encodeDisconnect,
|
|
6
|
+
encodePingReq,
|
|
7
|
+
encodePublish,
|
|
8
|
+
encodePuback,
|
|
9
|
+
encodeSubscribe,
|
|
10
|
+
type MqttPacket,
|
|
11
|
+
} from "./mqtt-codec";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Kênh MQTT của máy camera — vòng đời kết nối trên WebSocket.
|
|
15
|
+
*
|
|
16
|
+
* Vai trò trong kiến trúc: đây là "device plane". Presence do broker phát hiện
|
|
17
|
+
* ở tầng TCP (LWT) chứ không phải do server đoán từ nhịp heartbeat, nên máy
|
|
18
|
+
* chết được biết trong ~45s, và biết TỨC THÌ khi app đóng kết nối sạch. Lệnh
|
|
19
|
+
* start/stop cũng đi đường này; Convex websocket + poll 30s vẫn giữ nguyên làm
|
|
20
|
+
* lưới an toàn (broker sập không được giết match-start).
|
|
21
|
+
*
|
|
22
|
+
* Module này KHÔNG import `@pickleball/shared`: hợp đồng topic/ngưỡng do phía
|
|
23
|
+
* app truyền vào. Lý do là gói SDK này được publish ra npm và Metro đọc thẳng
|
|
24
|
+
* `src/`, nên một import workspace ở đây sẽ vỡ khi app ngoài repo cài gói.
|
|
25
|
+
* Nguồn duy nhất của hợp đồng vẫn là `packages/shared/src/realtime.ts` — chỉ
|
|
26
|
+
* khác là chỗ nối dây nằm ở app first-party.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Bề mặt WebSocket tối thiểu mà kênh cần — đủ để test thay bằng đồ giả. */
|
|
30
|
+
export interface MqttSocket {
|
|
31
|
+
binaryType: string;
|
|
32
|
+
send(data: ArrayBufferView | ArrayBuffer): void;
|
|
33
|
+
close(): void;
|
|
34
|
+
onopen: (() => void) | null;
|
|
35
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
36
|
+
onerror: ((event: unknown) => void) | null;
|
|
37
|
+
onclose: ((event: { code?: number }) => void) | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MqttChannelOptions {
|
|
41
|
+
/** wss://mqtt.cabinvn.com/mqtt */
|
|
42
|
+
url: string;
|
|
43
|
+
deviceId: string;
|
|
44
|
+
/** Token bền của máy — broker chuyển thẳng cho auth-hook Convex. */
|
|
45
|
+
deviceToken: string;
|
|
46
|
+
topics: { status: string; cmd: string; cfg: string; evt: string; score: string };
|
|
47
|
+
/** Payload retained lúc kết nối + payload LWT lúc rớt. */
|
|
48
|
+
statusPayload: (online: boolean) => string;
|
|
49
|
+
keepaliveS: number;
|
|
50
|
+
/** Thang backoff (ms); phần tử cuối được lặp lại mãi. */
|
|
51
|
+
reconnectDelaysMs: readonly number[];
|
|
52
|
+
onMessage?: (topic: string, payload: string) => void;
|
|
53
|
+
onConnectionChange?: (connected: boolean) => void;
|
|
54
|
+
/** Auth bị từ chối (token thu hồi / máy chưa claim) — để app log/khai báo. */
|
|
55
|
+
onAuthRejected?: (reason: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MqttChannelDeps {
|
|
59
|
+
createSocket?: (url: string, protocols: string[]) => MqttSocket;
|
|
60
|
+
setTimeout?: (fn: () => void, ms: number) => unknown;
|
|
61
|
+
clearTimeout?: (handle: unknown) => void;
|
|
62
|
+
now?: () => number;
|
|
63
|
+
/** Jitter ±20% chống bão reconnect đồng loạt sau khi broker restart. */
|
|
64
|
+
random?: () => number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Sức khoẻ kênh cho quyết định giãn nhịp beat. `isConnected()` KHÔNG đủ: nối
|
|
69
|
+
* được mà chưa SUBACK thì máy không nghe được lệnh nào, còn TCP mở mà broker
|
|
70
|
+
* chết thì `connected` vẫn true tới tận keepalive kế.
|
|
71
|
+
*/
|
|
72
|
+
export interface MqttChannelHealth {
|
|
73
|
+
connected: boolean;
|
|
74
|
+
subscribed: boolean;
|
|
75
|
+
lastInboundAt: number;
|
|
76
|
+
healthySince: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MqttChannel {
|
|
80
|
+
start(): void;
|
|
81
|
+
/** Đóng CHỦ ĐỘNG: gửi DISCONNECT nên broker KHÔNG bắn LWT. */
|
|
82
|
+
stop(): void;
|
|
83
|
+
isConnected(): boolean;
|
|
84
|
+
health(): MqttChannelHealth;
|
|
85
|
+
publish(topic: string, payload: string, opts?: { qos?: 0 | 1; retain?: boolean }): boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Auth hỏng thì đừng quay số theo thang thường: token bị thu hồi hoặc máy chưa
|
|
90
|
+
* được claim là trạng thái kéo dài hàng phút tới hàng giờ, mà mỗi lần thử là
|
|
91
|
+
* một lượt gọi auth-hook Convex. 5 phút là đủ thưa để 1.000 máy hỏng cùng lúc
|
|
92
|
+
* cũng không thành bão, vẫn đủ dày để máy vừa được claim tự vào trong 5 phút.
|
|
93
|
+
*/
|
|
94
|
+
const AUTH_RETRY_MS = 5 * 60_000;
|
|
95
|
+
|
|
96
|
+
export function createMqttChannel(
|
|
97
|
+
o: MqttChannelOptions,
|
|
98
|
+
deps: MqttChannelDeps = {},
|
|
99
|
+
): MqttChannel {
|
|
100
|
+
const createSocket =
|
|
101
|
+
deps.createSocket ??
|
|
102
|
+
((url, protocols) => new WebSocket(url, protocols) as unknown as MqttSocket);
|
|
103
|
+
const setTimer = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
104
|
+
const clearTimer = deps.clearTimeout ?? ((h) => clearTimeout(h as never));
|
|
105
|
+
const now = deps.now ?? (() => Date.now());
|
|
106
|
+
const random = deps.random ?? Math.random;
|
|
107
|
+
|
|
108
|
+
let socket: MqttSocket | null = null;
|
|
109
|
+
let buffer = new Uint8Array(0);
|
|
110
|
+
let connected = false;
|
|
111
|
+
let stopped = true;
|
|
112
|
+
let attempt = 0;
|
|
113
|
+
let packetId = 0;
|
|
114
|
+
let reconnectTimer: unknown = null;
|
|
115
|
+
let pingTimer: unknown = null;
|
|
116
|
+
let lastInboundAt = 0;
|
|
117
|
+
// Đã SUBACK đủ topic điều khiển chưa, và khoẻ liên tục từ lúc nào. Cả hai
|
|
118
|
+
// reset trong teardown: nối lại là phải chứng minh khoẻ lại từ đầu, không
|
|
119
|
+
// được thừa hưởng "đã khoẻ" của phiên kết nối trước.
|
|
120
|
+
let subscribed = false;
|
|
121
|
+
let healthySince = 0;
|
|
122
|
+
|
|
123
|
+
const nextPacketId = () => {
|
|
124
|
+
packetId = (packetId % 65_535) + 1;
|
|
125
|
+
return packetId;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const setConnected = (value: boolean) => {
|
|
129
|
+
if (connected === value) return;
|
|
130
|
+
connected = value;
|
|
131
|
+
o.onConnectionChange?.(value);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const clearTimers = () => {
|
|
135
|
+
if (reconnectTimer !== null) {
|
|
136
|
+
clearTimer(reconnectTimer);
|
|
137
|
+
reconnectTimer = null;
|
|
138
|
+
}
|
|
139
|
+
if (pingTimer !== null) {
|
|
140
|
+
clearTimer(pingTimer);
|
|
141
|
+
pingTimer = null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const send = (bytes: Uint8Array): boolean => {
|
|
146
|
+
if (!socket) return false;
|
|
147
|
+
try {
|
|
148
|
+
socket.send(bytes);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
// Socket vừa chết giữa chừng — đóng để rơi vào nhánh reconnect chung,
|
|
152
|
+
// đừng ném lên trên: kênh này không được phép làm sập engine.
|
|
153
|
+
teardown(true);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const scheduleReconnect = (delayMs?: number) => {
|
|
159
|
+
if (stopped || reconnectTimer !== null) return;
|
|
160
|
+
const base =
|
|
161
|
+
delayMs ??
|
|
162
|
+
o.reconnectDelaysMs[Math.min(attempt, o.reconnectDelaysMs.length - 1)] ??
|
|
163
|
+
30_000;
|
|
164
|
+
const jitter = base === 0 ? 0 : base * 0.2 * (random() * 2 - 1);
|
|
165
|
+
attempt += 1;
|
|
166
|
+
reconnectTimer = setTimer(() => {
|
|
167
|
+
reconnectTimer = null;
|
|
168
|
+
open();
|
|
169
|
+
}, Math.max(0, Math.round(base + jitter)));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const teardown = (reconnect: boolean, delayMs?: number) => {
|
|
173
|
+
clearTimers();
|
|
174
|
+
setConnected(false);
|
|
175
|
+
subscribed = false;
|
|
176
|
+
healthySince = 0;
|
|
177
|
+
buffer = new Uint8Array(0);
|
|
178
|
+
const dead = socket;
|
|
179
|
+
socket = null;
|
|
180
|
+
if (dead) {
|
|
181
|
+
dead.onopen = null;
|
|
182
|
+
dead.onmessage = null;
|
|
183
|
+
dead.onerror = null;
|
|
184
|
+
dead.onclose = null;
|
|
185
|
+
try {
|
|
186
|
+
dead.close();
|
|
187
|
+
} catch {
|
|
188
|
+
// Đóng socket đã chết không phải lỗi.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (reconnect) scheduleReconnect(delayMs);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Nhịp giữ kết nối. Gửi PINGREQ ở 3/4 keepalive, và nếu quá 1,5× keepalive mà
|
|
196
|
+
* KHÔNG nhận được byte nào thì tự coi là chết và nối lại — WiFi sân hay rơi
|
|
197
|
+
* vào cảnh socket "còn mở" trên giấy tờ nhưng không gói nào qua được nữa,
|
|
198
|
+
* lúc đó chỉ có phía mình tự nghi ngờ mới thoát ra được.
|
|
199
|
+
*/
|
|
200
|
+
const armPing = () => {
|
|
201
|
+
if (pingTimer !== null) clearTimer(pingTimer);
|
|
202
|
+
pingTimer = setTimer(() => {
|
|
203
|
+
pingTimer = null;
|
|
204
|
+
if (!socket) return;
|
|
205
|
+
if (now() - lastInboundAt > o.keepaliveS * 1500) {
|
|
206
|
+
teardown(true);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
send(encodePingReq());
|
|
210
|
+
armPing();
|
|
211
|
+
}, Math.max(1000, Math.round(o.keepaliveS * 750)));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handlePacket = (p: MqttPacket) => {
|
|
215
|
+
switch (p.type) {
|
|
216
|
+
case "connack": {
|
|
217
|
+
if (p.returnCode !== 0) {
|
|
218
|
+
const reason = CONNACK_REASON[p.returnCode] ?? `mã ${p.returnCode}`;
|
|
219
|
+
o.onAuthRejected?.(reason);
|
|
220
|
+
// 4/5 = sai creds hoặc bị auth-hook từ chối → nghỉ dài, xem AUTH_RETRY_MS.
|
|
221
|
+
teardown(true, p.returnCode === 4 || p.returnCode === 5 ? AUTH_RETRY_MS : undefined);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
attempt = 0;
|
|
225
|
+
setConnected(true);
|
|
226
|
+
// Trình tự sau CONNACK: khai "tôi sống" (retained, để dashboard thấy
|
|
227
|
+
// ngay cả khi bridge vừa khởi động lại) rồi mới nhận lệnh.
|
|
228
|
+
send(
|
|
229
|
+
encodePublish({
|
|
230
|
+
topic: o.topics.status,
|
|
231
|
+
payload: o.statusPayload(true),
|
|
232
|
+
qos: 1,
|
|
233
|
+
retain: true,
|
|
234
|
+
packetId: nextPacketId(),
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
send(
|
|
238
|
+
encodeSubscribe(nextPacketId(), [
|
|
239
|
+
{ topic: o.topics.cmd, qos: 1 },
|
|
240
|
+
{ topic: o.topics.cfg, qos: 1 },
|
|
241
|
+
// Tỉ số trận đang ghi (retained) — reconnect là nhận ngay bản mới nhất.
|
|
242
|
+
{ topic: o.topics.score, qos: 1 },
|
|
243
|
+
]),
|
|
244
|
+
);
|
|
245
|
+
armPing();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case "suback": {
|
|
249
|
+
// Broker xác nhận đã đăng ký topic điều khiển. Trước mốc này máy nối
|
|
250
|
+
// được nhưng KHÔNG nghe được lệnh nào — không tính là kênh khoẻ.
|
|
251
|
+
// Mã ≥ 0x80 là broker TỪ CHỐI (ACL) → giữ subscribed = false.
|
|
252
|
+
if (p.codes.every((c) => c < 0x80)) {
|
|
253
|
+
subscribed = true;
|
|
254
|
+
if (healthySince === 0) healthySince = now();
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
case "publish": {
|
|
259
|
+
// QoS1 phải ack, không thì broker gửi lại mãi.
|
|
260
|
+
if (p.qos === 1 && p.packetId !== undefined) send(encodePuback(p.packetId));
|
|
261
|
+
o.onMessage?.(p.topic, p.payload);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
default:
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const open = () => {
|
|
270
|
+
if (stopped || socket) return;
|
|
271
|
+
let s: MqttSocket;
|
|
272
|
+
try {
|
|
273
|
+
s = createSocket(o.url, ["mqtt"]);
|
|
274
|
+
} catch {
|
|
275
|
+
scheduleReconnect();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
socket = s;
|
|
279
|
+
s.binaryType = "arraybuffer";
|
|
280
|
+
lastInboundAt = now();
|
|
281
|
+
|
|
282
|
+
s.onopen = () => {
|
|
283
|
+
if (socket !== s) return;
|
|
284
|
+
send(
|
|
285
|
+
encodeConnect({
|
|
286
|
+
clientId: o.deviceId,
|
|
287
|
+
username: o.deviceId,
|
|
288
|
+
password: o.deviceToken,
|
|
289
|
+
keepaliveS: o.keepaliveS,
|
|
290
|
+
cleanSession: true,
|
|
291
|
+
will: {
|
|
292
|
+
topic: o.topics.status,
|
|
293
|
+
payload: o.statusPayload(false),
|
|
294
|
+
qos: 1,
|
|
295
|
+
retain: true,
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
s.onmessage = (event) => {
|
|
302
|
+
if (socket !== s) return;
|
|
303
|
+
const data = event.data;
|
|
304
|
+
let chunk: Uint8Array | null = null;
|
|
305
|
+
if (data instanceof ArrayBuffer) {
|
|
306
|
+
chunk = new Uint8Array(data);
|
|
307
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
308
|
+
// Copy chứ không tạo view lên buffer nền: runtime có thể tái dùng
|
|
309
|
+
// buffer đó cho frame sau, mà ta còn giữ phần gói dở qua nhiều lượt.
|
|
310
|
+
chunk = Uint8Array.from(data as unknown as ArrayLike<number>);
|
|
311
|
+
}
|
|
312
|
+
// RN trả Blob nếu binaryType không được tôn trọng — bỏ qua còn hơn ném.
|
|
313
|
+
if (!chunk) return;
|
|
314
|
+
lastInboundAt = now();
|
|
315
|
+
const merged = new Uint8Array(buffer.length + chunk.length);
|
|
316
|
+
merged.set(buffer, 0);
|
|
317
|
+
merged.set(chunk, buffer.length);
|
|
318
|
+
const { packets, rest } = decodePackets(merged);
|
|
319
|
+
// Copy phần dư (thường vài byte của gói chưa trọn) thay vì giữ subarray:
|
|
320
|
+
// view trỏ vào buffer cũ sẽ ghim cả frame vừa rồi trong bộ nhớ.
|
|
321
|
+
buffer = Uint8Array.from(rest);
|
|
322
|
+
for (const p of packets) handlePacket(p);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
s.onerror = () => {
|
|
326
|
+
if (socket !== s) return;
|
|
327
|
+
teardown(true);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
s.onclose = () => {
|
|
331
|
+
if (socket !== s) return;
|
|
332
|
+
teardown(true);
|
|
333
|
+
};
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
start() {
|
|
338
|
+
if (!stopped) return;
|
|
339
|
+
stopped = false;
|
|
340
|
+
attempt = 0;
|
|
341
|
+
open();
|
|
342
|
+
},
|
|
343
|
+
stop() {
|
|
344
|
+
if (stopped) return;
|
|
345
|
+
stopped = true;
|
|
346
|
+
// DISCONNECT sạch KHÔNG kích LWT, nên phải TỰ khai offline trước khi
|
|
347
|
+
// đóng: thiếu bước này thì máy chủ động gỡ ghép/tắt tính năng sẽ đứng
|
|
348
|
+
// "online" vĩnh viễn trên dashboard (retained cũ nằm lại trên broker).
|
|
349
|
+
if (socket && connected) {
|
|
350
|
+
send(
|
|
351
|
+
encodePublish({
|
|
352
|
+
topic: o.topics.status,
|
|
353
|
+
payload: o.statusPayload(false),
|
|
354
|
+
qos: 1,
|
|
355
|
+
retain: true,
|
|
356
|
+
packetId: nextPacketId(),
|
|
357
|
+
}),
|
|
358
|
+
);
|
|
359
|
+
send(encodeDisconnect());
|
|
360
|
+
}
|
|
361
|
+
teardown(false);
|
|
362
|
+
},
|
|
363
|
+
isConnected: () => connected,
|
|
364
|
+
health: () => ({ connected, subscribed, lastInboundAt, healthySince }),
|
|
365
|
+
publish(topic, payload, opts) {
|
|
366
|
+
if (!connected) return false;
|
|
367
|
+
const qos = opts?.qos ?? 0;
|
|
368
|
+
return send(
|
|
369
|
+
encodePublish({
|
|
370
|
+
topic,
|
|
371
|
+
payload,
|
|
372
|
+
qos,
|
|
373
|
+
retain: opts?.retain ?? false,
|
|
374
|
+
...(qos === 1 ? { packetId: nextPacketId() } : {}),
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|