@pickleball/expo-sdk 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +155 -101
  3. package/dist/advanced-DcbHBCGb.d.ts +300 -0
  4. package/dist/advanced-DeQwg7MK.d.cts +300 -0
  5. package/dist/advanced.cjs +14 -2
  6. package/dist/advanced.d.cts +2 -2
  7. package/dist/advanced.d.ts +2 -2
  8. package/dist/advanced.js +17 -5
  9. package/dist/camera-controls-By8tHigY.d.cts +97 -0
  10. package/dist/camera-controls-rCBUGGPC.d.ts +97 -0
  11. package/dist/chunk-62YV3PCD.cjs +139 -0
  12. package/dist/chunk-6F3HN6US.js +50 -0
  13. package/dist/chunk-6YE5BL3A.cjs +50 -0
  14. package/dist/chunk-7Z7TN5N3.js +318 -0
  15. package/dist/{chunk-TRLASNPQ.cjs → chunk-GXQYMPHE.cjs} +1 -1
  16. package/dist/{chunk-TBPR2QND.cjs → chunk-IUIRVKJI.cjs} +736 -399
  17. package/dist/chunk-TBUEXRFQ.cjs +318 -0
  18. package/dist/{chunk-FODE2W2H.js → chunk-XGP2MN5R.js} +688 -351
  19. package/dist/{chunk-6JOMESNT.js → chunk-XL4CISVQ.js} +1 -1
  20. package/dist/chunk-ZSKSXOCZ.js +139 -0
  21. package/dist/convex-command-subscription-Cabh9tND.d.ts +41 -0
  22. package/dist/convex-command-subscription-DUgpu75l.d.cts +41 -0
  23. package/dist/device-agent/index.cjs +973 -44
  24. package/dist/device-agent/index.d.cts +339 -3
  25. package/dist/device-agent/index.d.ts +339 -3
  26. package/dist/device-agent/index.js +940 -11
  27. package/dist/{engine-CCSSqPbZ.d.cts → engine-BSvSdCSE.d.cts} +227 -2
  28. package/dist/{engine-CCSSqPbZ.d.ts → engine-BSvSdCSE.d.ts} +227 -2
  29. package/dist/index.cjs +419 -937
  30. package/dist/index.d.cts +315 -32
  31. package/dist/index.d.ts +315 -32
  32. package/dist/index.js +423 -941
  33. package/dist/plugin.cjs +11 -13
  34. package/dist/plugin.d.cts +15 -1
  35. package/dist/plugin.d.ts +15 -1
  36. package/dist/plugin.js +7 -9
  37. package/dist/realtime/index.cjs +31 -0
  38. package/dist/realtime/index.d.cts +15 -0
  39. package/dist/realtime/index.d.ts +15 -0
  40. package/dist/realtime/index.js +31 -0
  41. package/dist/ui/index.cjs +1297 -0
  42. package/dist/ui/index.d.cts +493 -0
  43. package/dist/ui/index.d.ts +493 -0
  44. package/dist/ui/index.js +1297 -0
  45. package/dist/version.cjs +2 -2
  46. package/dist/version.js +1 -1
  47. package/package.json +31 -15
  48. package/src/advanced.ts +11 -9
  49. package/src/auto-resume.tsx +259 -0
  50. package/src/camera-controls.ts +99 -0
  51. package/src/camera-grant-provider.ts +392 -0
  52. package/src/camera-settings.ts +133 -0
  53. package/src/contracts.ts +73 -0
  54. package/src/device-agent/agent.ts +262 -9
  55. package/src/device-agent/beat-policy.ts +46 -0
  56. package/src/device-agent/component.tsx +11 -0
  57. package/src/device-agent/convex-beat-transport.ts +140 -0
  58. package/src/device-agent/convex-command-subscription.ts +101 -0
  59. package/src/device-agent/convex-control-channel.ts +7 -1
  60. package/src/device-agent/http-transport.ts +22 -1
  61. package/src/device-agent/index.ts +22 -0
  62. package/src/device-agent/mqtt-channel.ts +379 -0
  63. package/src/device-agent/mqtt-codec.ts +347 -0
  64. package/src/device-agent/tournament.ts +352 -0
  65. package/src/engine.ts +580 -15
  66. package/src/http-session-provider.ts +21 -189
  67. package/src/index.ts +84 -5
  68. package/src/local-video-quality.ts +43 -0
  69. package/src/native-runtime.ts +234 -78
  70. package/src/plugin.ts +19 -6
  71. package/src/provider.tsx +94 -18
  72. package/src/realtime/index.ts +56 -0
  73. package/src/resume-policy.ts +85 -0
  74. package/src/resume.ts +132 -0
  75. package/src/rtmp-room-adapter.ts +164 -12
  76. package/src/session-grant.ts +256 -0
  77. package/src/ui/camera-screen.tsx +484 -0
  78. package/src/ui/controls-sheet.tsx +445 -0
  79. package/src/ui/copy.ts +326 -0
  80. package/src/ui/helpers.ts +105 -0
  81. package/src/ui/hooks.ts +59 -0
  82. package/src/ui/index.ts +42 -0
  83. package/src/ui/preview-view.tsx +39 -0
  84. package/src/ui/primitives.tsx +304 -0
  85. package/src/uplink-check.ts +173 -0
  86. package/dist/advanced-BXDrDl1h.d.ts +0 -316
  87. package/dist/advanced-BdAa2Erv.d.cts +0 -316
  88. package/src/livekit-room-adapter.ts +0 -390
  89. package/src/livestream-screen.tsx +0 -1002
@@ -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" && record.type !== "stop_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) return { controlSession: null };
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
+ }