@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
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ConvexClient } from "convex/browser";
|
|
2
|
+
import { anyApi } from "convex/server";
|
|
3
|
+
import type { SdkTelemetryCredentials, SessionCommandRecord } from "../contracts";
|
|
4
|
+
import type { SessionCommandSubscription } from "../engine";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Kênh nhận lệnh device-rtmp qua WEBSOCKET (Convex subscription) thay poll HTTP
|
|
8
|
+
* 2 giây. Vì sao đổi: poll 2s là 2 function call mỗi 2 giây MỖI MÁY chạy suốt
|
|
9
|
+
* ngày đêm — ở 200 máy là ~518 triệu call/tháng, gấp 20 lần quota gói Pro.
|
|
10
|
+
* Subscription chỉ chạy lại (và tính tiền) KHI hàng lệnh của phiên đổi: lệnh về
|
|
11
|
+
* tức thì (<1s), còn lúc rảnh gần như 0.
|
|
12
|
+
*
|
|
13
|
+
* CHỈ dùng cho máy FIRST-PARTY nối thẳng Convex (có URL .convex.cloud). Máy đối
|
|
14
|
+
* tác đi qua proxy không có đường websocket trực tiếp → giữ nguyên poll HTTP.
|
|
15
|
+
*
|
|
16
|
+
* Auth: gửi telemetry token THÔ làm tham số query, backend hash server-side
|
|
17
|
+
* (sessionCommands.pendingForSession) — client không có crypto. Cùng lối với
|
|
18
|
+
* deviceControl.pendingCommands (cũng nhận token thô trong query).
|
|
19
|
+
*
|
|
20
|
+
* ConvexClient được dựng MỘT lần và tái dùng qua mọi phiên: engine chỉ
|
|
21
|
+
* subscribe/unsubscribe theo phiên, KHÔNG đóng client giữa chừng (đóng rồi mở
|
|
22
|
+
* lại là bắt tay websocket mới mỗi lần đổi độ phân giải/phiên).
|
|
23
|
+
*/
|
|
24
|
+
export function createConvexCommandSubscription(
|
|
25
|
+
convexUrl: string,
|
|
26
|
+
): SessionCommandSubscription {
|
|
27
|
+
return createConvexDeviceRealtime(convexUrl).commandSubscription;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mọi kênh Convex realtime của máy first-party trên MỘT websocket duy nhất:
|
|
32
|
+
* subscription lệnh (đợt 2) + heartbeat 1-call (đợt 3). Convex client tự
|
|
33
|
+
* multiplex query/mutation trên cùng socket — dựng hai client là trả giá hai
|
|
34
|
+
* lần bắt tay + hai kết nối phải nuôi.
|
|
35
|
+
*/
|
|
36
|
+
export interface ConvexDeviceRealtime {
|
|
37
|
+
commandSubscription: SessionCommandSubscription;
|
|
38
|
+
/**
|
|
39
|
+
* Heartbeat qua websocket = 1 function call (mutation `deviceApp.beat`),
|
|
40
|
+
* thay cho đường HTTP httpAction→runMutation = 2 call. Trả envelope `data`
|
|
41
|
+
* thô cùng hợp đồng với POST /devices/heartbeat. NÉM lỗi khi thất bại —
|
|
42
|
+
* caller (withConvexBeat) tự rơi về HTTP.
|
|
43
|
+
*/
|
|
44
|
+
beat(args: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
45
|
+
dispose(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createConvexDeviceRealtime(convexUrl: string): ConvexDeviceRealtime {
|
|
49
|
+
const client = new ConvexClient(convexUrl);
|
|
50
|
+
return {
|
|
51
|
+
commandSubscription: {
|
|
52
|
+
subscribe(
|
|
53
|
+
credentials: SdkTelemetryCredentials,
|
|
54
|
+
sessionId: string,
|
|
55
|
+
onCommands: (commands: SessionCommandRecord[]) => void,
|
|
56
|
+
onError?: (error: Error) => void,
|
|
57
|
+
) {
|
|
58
|
+
return client.onUpdate(
|
|
59
|
+
anyApi.sessionCommands.pendingForSession,
|
|
60
|
+
{ sessionId, token: credentials.token, after: 0 },
|
|
61
|
+
(value) => onCommands(toCommands(value)),
|
|
62
|
+
(error) => onError?.(error),
|
|
63
|
+
);
|
|
64
|
+
},
|
|
65
|
+
// Client dùng chung với beat — đóng ở dispose() của cả cụm, không ở đây.
|
|
66
|
+
dispose() {},
|
|
67
|
+
},
|
|
68
|
+
async beat(args) {
|
|
69
|
+
const result = await client.mutation(anyApi.deviceApp.beat, args);
|
|
70
|
+
return result !== null && typeof result === "object" && !Array.isArray(result)
|
|
71
|
+
? (result as Record<string, unknown>)
|
|
72
|
+
: {};
|
|
73
|
+
},
|
|
74
|
+
dispose() {
|
|
75
|
+
void client.close();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Lọc payload query thành mảng lệnh hợp lệ — bỏ mọi thứ không đúng dạng. */
|
|
81
|
+
function toCommands(value: unknown): SessionCommandRecord[] {
|
|
82
|
+
if (value === null || typeof value !== "object") return [];
|
|
83
|
+
const commands = (value as { commands?: unknown }).commands;
|
|
84
|
+
if (!Array.isArray(commands)) return [];
|
|
85
|
+
const out: SessionCommandRecord[] = [];
|
|
86
|
+
for (const item of commands) {
|
|
87
|
+
if (item === null || typeof item !== "object") continue;
|
|
88
|
+
const record = item as Record<string, unknown>;
|
|
89
|
+
if (
|
|
90
|
+
typeof record.id === "string" &&
|
|
91
|
+
(record.action === "start" ||
|
|
92
|
+
record.action === "stop" ||
|
|
93
|
+
record.action === "end") &&
|
|
94
|
+
typeof record.issuedAt === "number" &&
|
|
95
|
+
Number.isFinite(record.issuedAt)
|
|
96
|
+
) {
|
|
97
|
+
out.push({ id: record.id, action: record.action, issuedAt: record.issuedAt });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
@@ -20,7 +20,10 @@ export function createConvexControlChannel(convexUrl: string): DeviceControlChan
|
|
|
20
20
|
const record = item as Record<string, unknown>;
|
|
21
21
|
if (
|
|
22
22
|
typeof record.id !== "string" ||
|
|
23
|
-
(record.type !== "start_livestream" &&
|
|
23
|
+
(record.type !== "start_livestream" &&
|
|
24
|
+
record.type !== "stop_livestream" &&
|
|
25
|
+
record.type !== "publish_livestream" &&
|
|
26
|
+
record.type !== "unpublish_livestream") ||
|
|
24
27
|
typeof record.expiresAt !== "number"
|
|
25
28
|
) {
|
|
26
29
|
continue;
|
|
@@ -39,6 +42,9 @@ export function createConvexControlChannel(convexUrl: string): DeviceControlChan
|
|
|
39
42
|
...(payload.visibility === "public" || payload.visibility === "private"
|
|
40
43
|
? { visibility: payload.visibility }
|
|
41
44
|
: {}),
|
|
45
|
+
...(typeof payload.courtRef === "string" && payload.courtRef.trim() !== ""
|
|
46
|
+
? { courtRef: payload.courtRef }
|
|
47
|
+
: {}),
|
|
42
48
|
},
|
|
43
49
|
expiresAt: record.expiresAt,
|
|
44
50
|
});
|
|
@@ -24,6 +24,13 @@ export interface HttpDeviceAgentTransportOptions {
|
|
|
24
24
|
headers?: HeadersInput | (() => HeadersInput | Promise<HeadersInput>);
|
|
25
25
|
timeoutMs?: number;
|
|
26
26
|
paths?: Partial<{ register: string; heartbeat: string }>;
|
|
27
|
+
/**
|
|
28
|
+
* Nhận envelope `data` thô sau khi transport validate xong. Validator phía
|
|
29
|
+
* dưới drop field ngoài contract (deviceToken/pairing/claimed/ownerName của
|
|
30
|
+
* đường first-party) — app device-streamer đọc chúng qua hook này; contract
|
|
31
|
+
* partner cũ không đổi.
|
|
32
|
+
*/
|
|
33
|
+
onData?: (endpoint: "register" | "heartbeat", data: Record<string, unknown>) => void;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
const defaultPaths = {
|
|
@@ -94,6 +101,7 @@ export function createHttpDeviceAgentTransport(
|
|
|
94
101
|
) {
|
|
95
102
|
throw new InvalidResponseError();
|
|
96
103
|
}
|
|
104
|
+
options.onData?.("register", data);
|
|
97
105
|
return {
|
|
98
106
|
deviceId: data.deviceId,
|
|
99
107
|
heartbeatIntervalMs: data.heartbeatIntervalMs,
|
|
@@ -102,8 +110,20 @@ export function createHttpDeviceAgentTransport(
|
|
|
102
110
|
async heartbeat(input: BootstrapInput & DeviceHeartbeatBody) {
|
|
103
111
|
const data = await post(paths.heartbeat, input);
|
|
104
112
|
if (!isRecord(data)) throw new InvalidResponseError();
|
|
113
|
+
options.onData?.("heartbeat", data);
|
|
114
|
+
// Nhịp server gợi ý cho lượt beat kế (adaptive heartbeat). Transport là
|
|
115
|
+
// NƠI DUY NHẤT dữ liệu beat đi qua, nên field nào không được chuyển tiếp
|
|
116
|
+
// ở đây thì agent vĩnh viễn không thấy — dù server trả đúng và app đã có
|
|
117
|
+
// code đọc. Đúng ca đã mất một buổi truy 02/09: server trả 60s, bundle
|
|
118
|
+
// app có code mới, mà máy vẫn beat 12s vì hint bị chặn ngay chỗ này.
|
|
119
|
+
const beatIntervalMs =
|
|
120
|
+
typeof data.beatIntervalMs === "number" && Number.isFinite(data.beatIntervalMs)
|
|
121
|
+
? { beatIntervalMs: data.beatIntervalMs }
|
|
122
|
+
: {};
|
|
105
123
|
const grant = data.controlSession;
|
|
106
|
-
if (grant === null || grant === undefined)
|
|
124
|
+
if (grant === null || grant === undefined) {
|
|
125
|
+
return { controlSession: null, ...beatIntervalMs };
|
|
126
|
+
}
|
|
107
127
|
if (
|
|
108
128
|
!isRecord(grant) ||
|
|
109
129
|
typeof grant.id !== "string" ||
|
|
@@ -119,6 +139,7 @@ export function createHttpDeviceAgentTransport(
|
|
|
119
139
|
token: grant.token,
|
|
120
140
|
convexUrl: grant.convexUrl,
|
|
121
141
|
} satisfies DeviceControlSessionGrant,
|
|
142
|
+
...beatIntervalMs,
|
|
122
143
|
};
|
|
123
144
|
},
|
|
124
145
|
};
|
|
@@ -1,4 +1,26 @@
|
|
|
1
1
|
export * from "./agent";
|
|
2
2
|
export * from "./http-transport";
|
|
3
|
+
export * from "./tournament";
|
|
3
4
|
export { createConvexControlChannel } from "./convex-control-channel";
|
|
5
|
+
export {
|
|
6
|
+
createConvexCommandSubscription,
|
|
7
|
+
createConvexDeviceRealtime,
|
|
8
|
+
type ConvexDeviceRealtime,
|
|
9
|
+
} from "./convex-command-subscription";
|
|
10
|
+
export { withConvexBeat, type ConvexBeatOptions } from "./convex-beat-transport";
|
|
4
11
|
export { PickleballDeviceAgent, type PickleballDeviceAgentProps } from "./component";
|
|
12
|
+
export { createMqttChannel } from "./mqtt-channel";
|
|
13
|
+
export type {
|
|
14
|
+
MqttChannel,
|
|
15
|
+
MqttChannelDeps,
|
|
16
|
+
MqttChannelHealth,
|
|
17
|
+
MqttChannelOptions,
|
|
18
|
+
MqttSocket,
|
|
19
|
+
} from "./mqtt-channel";
|
|
20
|
+
export {
|
|
21
|
+
BEAT_FAST_MS,
|
|
22
|
+
BEAT_SLOW_MS,
|
|
23
|
+
mqttBeatHealthy,
|
|
24
|
+
nextBeatDelayMs,
|
|
25
|
+
type MqttBeatHealth,
|
|
26
|
+
} from "./beat-policy";
|
|
@@ -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
|
+
}
|