ota-hub-reactjs 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 [OTA Hub and the Hard Stuff team]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
Binary file
@@ -0,0 +1,42 @@
1
+ export type LogMessage = string | number | boolean | Record<string, any> | any[];
2
+ export type LogLine = {
3
+ level: number;
4
+ message: LogMessage;
5
+ timestamp?: string | Date;
6
+ };
7
+ type PropCreatorProps<T> = (uuid: string) => Partial<T> | undefined;
8
+ export interface AddConnectionProps<T> {
9
+ uuid?: string;
10
+ propCreator?: PropCreatorProps<T>;
11
+ }
12
+ export type DeviceConnectionState = {
13
+ uuid: string;
14
+ deviceMac?: string;
15
+ name: string;
16
+ send: (data: string | Uint8Array) => void | Promise<void>;
17
+ onReceive?: (data: string | Uint8Array) => boolean;
18
+ onConnect?: () => void | Promise<void>;
19
+ onDisconnect?: () => void | Promise<void>;
20
+ autoConnect: boolean;
21
+ isConnected: boolean;
22
+ isConnecting: boolean;
23
+ logs: LogLine[];
24
+ readBufferLeftover: string;
25
+ };
26
+ export declare function createDefaultInitialDeviceState<T extends DeviceConnectionState>(uuid: string, props?: any): T;
27
+ export type DeviceWhispererProps<T extends DeviceConnectionState> = {
28
+ createInitialConnectionState?: (uuid: string) => T;
29
+ };
30
+ export declare function MultiDeviceWhisperer<T extends DeviceConnectionState>({ createInitialConnectionState, }?: DeviceWhispererProps<T>): {
31
+ connections: T[];
32
+ connectionsRef: import("react").RefObject<T[]>;
33
+ addConnection: ({ uuid, propCreator }: AddConnectionProps<T>) => Promise<string>;
34
+ removeConnection: (uuid: string) => void;
35
+ connect: (_uuid: string) => void;
36
+ disconnect: (_uuid: string) => void;
37
+ updateConnection: (uuid: string, updater: (c: T) => T) => void;
38
+ reconnectAll: () => void;
39
+ updateConnectionName: (uuid: string, name: string) => void;
40
+ appendLog: (uuid: string, log: LogLine) => void;
41
+ };
42
+ export {};
@@ -0,0 +1,75 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { uniqueNamesGenerator, animals } from "unique-names-generator";
4
+ ;
5
+ // Initial, generic state for the generic device
6
+ export function createDefaultInitialDeviceState(uuid, props) {
7
+ return {
8
+ uuid,
9
+ autoConnect: true,
10
+ isConnected: false,
11
+ isConnecting: false,
12
+ logs: [],
13
+ readBufferLeftover: "",
14
+ ...props
15
+ };
16
+ }
17
+ /* One Device Whisperer is used for all like-devices, such as all Serial with Protobuf.
18
+ Any e.g. Serial without Protobuf, or LoRaWAN etc. devices would be handled via a separate device whisperer
19
+ */
20
+ export function MultiDeviceWhisperer({ createInitialConnectionState = createDefaultInitialDeviceState, } = {}) {
21
+ const [connections, setConnections] = useState([]);
22
+ const connectionsRef = useRef(connections);
23
+ const updateConnection = (uuid, updater) => {
24
+ setConnections(prev => {
25
+ const updated = prev.map((c) => c.uuid === uuid ? updater(c) : c);
26
+ connectionsRef.current = updated;
27
+ return updated;
28
+ });
29
+ };
30
+ const updateConnectionName = (uuid, name) => {
31
+ setConnections((prev) => prev.map((c) => (c.uuid === uuid ? { ...c, name } : c)));
32
+ };
33
+ const appendLog = (uuid, log) => {
34
+ if (!log.timestamp) {
35
+ log.timestamp = new Date();
36
+ }
37
+ updateConnection(uuid, (c) => ({
38
+ ...c,
39
+ logs: [...c.logs.slice(-199), log],
40
+ }));
41
+ };
42
+ const addConnection = async ({ uuid, propCreator }) => {
43
+ uuid = uuid ?? uniqueNamesGenerator({ dictionaries: [animals] });
44
+ const props = propCreator?.(uuid);
45
+ const newConnection = {
46
+ ...createInitialConnectionState(uuid),
47
+ ...props
48
+ };
49
+ connectionsRef.current = [...connectionsRef.current, newConnection];
50
+ setConnections(prev => [...prev, newConnection]);
51
+ const anyUpdatedConnection = connectionsRef.current.find(c => c.uuid === uuid);
52
+ if (!anyUpdatedConnection) {
53
+ return "";
54
+ }
55
+ return uuid;
56
+ };
57
+ const removeConnection = (uuid) => {
58
+ setConnections((prev) => prev.filter((c) => c.uuid !== uuid));
59
+ };
60
+ useEffect(() => {
61
+ connectionsRef.current = connections;
62
+ }, [connections]);
63
+ return {
64
+ connections,
65
+ connectionsRef,
66
+ addConnection,
67
+ removeConnection,
68
+ connect: (_uuid) => { },
69
+ disconnect: (_uuid) => { },
70
+ updateConnection,
71
+ reconnectAll: () => { },
72
+ updateConnectionName,
73
+ appendLog
74
+ };
75
+ }
@@ -0,0 +1,4 @@
1
+ export * from "@/base/device-whisperer.js";
2
+ export * from "@/message_layers/protobuf-wrapper.js";
3
+ export * from "@/transport_layers/serial-device-whisperer.js";
4
+ export * from "@/transport_layers/websocket-device-whisperer.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Re-export core types/classes
2
+ export * from "@/base/device-whisperer.js";
3
+ // Message layer exports
4
+ export * from "@/message_layers/protobuf-wrapper.js";
5
+ // Transport layers
6
+ export * from "@/transport_layers/serial-device-whisperer.js";
7
+ export * from "@/transport_layers/websocket-device-whisperer.js";
@@ -0,0 +1,31 @@
1
+ import { AddConnectionProps, DeviceConnectionState, MultiDeviceWhisperer } from "@/base/device-whisperer.js";
2
+ export type TopicHandlerContext<AppLayer extends DeviceConnectionState> = {
3
+ base: ReturnType<typeof MultiDeviceWhisperer<AppLayer>>;
4
+ uuid: string;
5
+ };
6
+ export type TopicHandlerMap<AppLayer extends DeviceConnectionState, Topic extends string | number, Message = any> = {
7
+ [K in Topic]?: (message: Message, context: TopicHandlerContext<AppLayer>) => void;
8
+ };
9
+ export type ProtobufDeviceWhispererProps<AppLayer extends DeviceConnectionState, Topic extends string | number, MessageRX = any, MessageTX = any> = {
10
+ transportLayer: ReturnType<typeof MultiDeviceWhisperer<AppLayer>>;
11
+ encodeRX: (message: MessageTX) => Uint8Array;
12
+ decodeTX: (bytes: Uint8Array) => MessageRX;
13
+ messageTypeField: keyof MessageRX;
14
+ rxTopicHandlerMap: TopicHandlerMap<AppLayer, Topic, MessageRX>;
15
+ HEADER?: Uint8Array;
16
+ expectLength?: boolean;
17
+ };
18
+ export declare function ProtobufMultiDeviceWhisperer<AppLayer extends DeviceConnectionState, Topic extends string | number, MessageRX = any, MessageTX = any>({ transportLayer, encodeRX, decodeTX, messageTypeField, rxTopicHandlerMap, HEADER, }: ProtobufDeviceWhispererProps<AppLayer, Topic, MessageRX, MessageTX>): {
19
+ addConnection: ({ uuid, propCreator }: AddConnectionProps<AppLayer>) => Promise<string>;
20
+ sendProtobuf: (uuid: string, message: MessageTX) => void;
21
+ protoBufOnReceiveHandler: (uuid: string, data: string | Uint8Array) => void;
22
+ connections: AppLayer[];
23
+ connectionsRef: import("react").RefObject<AppLayer[]>;
24
+ removeConnection: (uuid: string) => void;
25
+ connect: (_uuid: string) => void;
26
+ disconnect: (_uuid: string) => void;
27
+ updateConnection: (uuid: string, updater: (c: AppLayer) => AppLayer) => void;
28
+ reconnectAll: () => void;
29
+ updateConnectionName: (uuid: string, name: string) => void;
30
+ appendLog: (uuid: string, log: import("@/base/device-whisperer.js").LogLine) => void;
31
+ };
@@ -0,0 +1,142 @@
1
+ export function ProtobufMultiDeviceWhisperer({ transportLayer, encodeRX, decodeTX, messageTypeField, rxTopicHandlerMap, HEADER = new Uint8Array([]), }) {
2
+ // --- Utils ---
3
+ const concatUint8Arrays = (a, b) => {
4
+ const result = new Uint8Array(a.length + b.length);
5
+ result.set(a);
6
+ result.set(b, a.length);
7
+ return result;
8
+ };
9
+ const wrapLengthPrefixed = (payload) => {
10
+ const length = payload.length;
11
+ const header = new Uint8Array([
12
+ (length >> 8) & 0xff, // Most significant byte first
13
+ length & 0xff // Least significant byte second
14
+ ]);
15
+ return concatUint8Arrays(header, payload);
16
+ };
17
+ const tryDecodeLengthPrefixed = (buffer) => {
18
+ if (HEADER.length) {
19
+ const findHeader = (buf) => {
20
+ for (let i = 0; i < buf.length - 1; i++) {
21
+ if (buf[i] === HEADER[0] && buf[i + 1] === HEADER[1])
22
+ return i;
23
+ }
24
+ return -1;
25
+ };
26
+ const headerIndex = findHeader(buffer);
27
+ if (headerIndex === -1)
28
+ return [null, buffer];
29
+ buffer = buffer.slice(headerIndex);
30
+ }
31
+ const st_msg = HEADER.length + 2;
32
+ if (buffer.length < st_msg)
33
+ return [null, buffer];
34
+ const length = (buffer[HEADER.length] << 8) | buffer[HEADER.length + 1];
35
+ if (buffer.length < st_msg + length)
36
+ return [null, buffer];
37
+ const messageBytes = buffer.slice(st_msg, st_msg + length);
38
+ const leftover = buffer.slice(st_msg + length);
39
+ try {
40
+ const decoded = decodeTX(messageBytes);
41
+ return [decoded, leftover];
42
+ }
43
+ catch {
44
+ return [null, buffer.slice(1)];
45
+ }
46
+ };
47
+ // --- New outbound API ---
48
+ const sendProtobuf = (uuid, message) => {
49
+ const encoded = encodeRX(message);
50
+ const wrapped = wrapLengthPrefixed(encoded);
51
+ const conn = transportLayer.connectionsRef.current.find(c => c.uuid === uuid);
52
+ console.log("Sending Protobuf:", message, "bytes: ", [...wrapped]);
53
+ conn?.send?.(wrapped);
54
+ };
55
+ // --- Inbound buffering ---
56
+ const buffers = {};
57
+ const protoBufOnReceiveHandler = (uuid, data) => {
58
+ const bytes = typeof data === "string"
59
+ ? new TextEncoder().encode(data)
60
+ : data;
61
+ buffers[uuid] = concatUint8Arrays(buffers[uuid] || new Uint8Array(), bytes);
62
+ let buffer = buffers[uuid];
63
+ while (buffer.length > 0) {
64
+ const [msg, remaining] = tryDecodeLengthPrefixed(buffer);
65
+ if (msg) {
66
+ buffers[uuid] = remaining;
67
+ const topic = msg[messageTypeField];
68
+ const handler = rxTopicHandlerMap[topic];
69
+ if (handler) {
70
+ try {
71
+ handler(msg, { base: transportLayer, uuid });
72
+ }
73
+ catch (err) {
74
+ transportLayer.appendLog(uuid, {
75
+ level: 0,
76
+ message: `[!] Error in handler for topic "${topic}": ${err}`
77
+ });
78
+ }
79
+ }
80
+ else {
81
+ transportLayer.appendLog(uuid, {
82
+ level: 1,
83
+ message: `[!] Unknown Protobuf topic: "${topic}"`
84
+ });
85
+ }
86
+ buffer = buffers[uuid];
87
+ continue;
88
+ }
89
+ // If not protobuf, see if we can log a line of text
90
+ const newlineIdx = buffer.indexOf(10); // '\n'
91
+ const headerIdx = buffer.findIndex((_, i) => HEADER.every((h, idx) => buffer[i] + idx === h));
92
+ if (newlineIdx !== -1 && (headerIdx === -1 || newlineIdx < headerIdx)) {
93
+ const line = buffer.slice(0, newlineIdx);
94
+ buffer = buffer.slice(newlineIdx + 1);
95
+ buffers[uuid] = buffer;
96
+ const text = new TextDecoder().decode(line).trim();
97
+ if (text) {
98
+ transportLayer.appendLog(uuid, {
99
+ level: 2,
100
+ message: text
101
+ });
102
+ }
103
+ continue;
104
+ }
105
+ if (headerIdx > 0) {
106
+ const garbage = buffer.slice(0, headerIdx);
107
+ buffer = buffer.slice(headerIdx);
108
+ buffers[uuid] = buffer;
109
+ const preview = Array.from(garbage)
110
+ .slice(0, 16)
111
+ .map(b => b.toString(16).padStart(2, '0'))
112
+ .join(' ');
113
+ transportLayer.appendLog(uuid, {
114
+ level: 1,
115
+ message: `[!] Skipped invalid bytes before Protobuf header: ${preview}... (${garbage.length} bytes)`
116
+ });
117
+ continue;
118
+ }
119
+ break;
120
+ }
121
+ buffers[uuid] = buffer;
122
+ };
123
+ // --- Override addConnection to wrap onReceive ---
124
+ const addConnection = async ({ uuid, propCreator }) => {
125
+ return await transportLayer.addConnection({
126
+ uuid,
127
+ propCreator: (id) => {
128
+ const props = propCreator?.(id);
129
+ return {
130
+ onReceive: (data) => protoBufOnReceiveHandler(id, data),
131
+ ...props
132
+ };
133
+ }
134
+ });
135
+ };
136
+ return {
137
+ ...transportLayer,
138
+ addConnection,
139
+ sendProtobuf,
140
+ protoBufOnReceiveHandler
141
+ };
142
+ }
@@ -0,0 +1,31 @@
1
+ import { FlashOptions, Transport } from "esptool-js";
2
+ import { DeviceConnectionState, AddConnectionProps } from "@/base/device-whisperer.js";
3
+ export type SerialConnectionState = DeviceConnectionState & {
4
+ port?: SerialPort;
5
+ baudrate?: number;
6
+ transport?: Transport;
7
+ isFlashing: boolean;
8
+ flashProgress: number;
9
+ flashError: string;
10
+ };
11
+ export type FlashFirmwareProps = {
12
+ uuid: string;
13
+ firmwareBlob?: Blob;
14
+ fileArray?: FlashOptions["fileArray"];
15
+ };
16
+ export declare function SerialMultiDeviceWhisperer<AppOrMessageLayer extends SerialConnectionState>({ ...props }?: {}): {
17
+ addConnection: ({ uuid, propCreator }: AddConnectionProps<AppOrMessageLayer>) => Promise<string>;
18
+ removeConnection: (uuid: string) => Promise<void>;
19
+ connect: (uuid: string, baudrate?: number, restart_on_connect?: boolean) => Promise<void>;
20
+ disconnect: (uuid: string, timeout?: number) => Promise<void>;
21
+ handleFlashFirmware: (uuid: string, assetsToFlash: {
22
+ blob: Blob;
23
+ address: number;
24
+ }[]) => Promise<void>;
25
+ reconnectAll: (...connectionProps: any) => Promise<void>;
26
+ connections: AppOrMessageLayer[];
27
+ connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
28
+ updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
29
+ updateConnectionName: (uuid: string, name: string) => void;
30
+ appendLog: (uuid: string, log: import("@/base/device-whisperer.js").LogLine) => void;
31
+ };
@@ -0,0 +1,290 @@
1
+ import { ESPLoader, Transport } from "esptool-js";
2
+ import { MultiDeviceWhisperer } from "@/base/device-whisperer.js";
3
+ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
4
+ const base = MultiDeviceWhisperer(props);
5
+ const defaultOnReceive = (uuid, data) => {
6
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
7
+ if (!conn)
8
+ return;
9
+ const decoder = new TextDecoder();
10
+ let bytes;
11
+ if (typeof data === "string") {
12
+ bytes = new TextEncoder().encode(data);
13
+ }
14
+ else if (data instanceof ArrayBuffer) {
15
+ bytes = new Uint8Array(data);
16
+ }
17
+ else {
18
+ bytes = data;
19
+ }
20
+ const asText = decoder.decode(bytes);
21
+ const combined = conn.readBufferLeftover + asText;
22
+ const lines = combined.split("\r\n");
23
+ base.updateConnection(uuid, (c) => ({
24
+ ...c,
25
+ readBufferLeftover: lines.pop() || ""
26
+ }));
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (trimmed) {
30
+ base.appendLog(uuid, {
31
+ level: 2,
32
+ message: trimmed,
33
+ });
34
+ }
35
+ }
36
+ };
37
+ const defaultSend = async (uuid, data) => {
38
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
39
+ if (!conn)
40
+ return;
41
+ const asString = typeof data === "string"
42
+ ? data
43
+ : btoa(String.fromCharCode(...data));
44
+ base.appendLog(uuid, {
45
+ level: 3,
46
+ message: asString,
47
+ });
48
+ const bytes = typeof data === "string"
49
+ ? new TextEncoder().encode(data)
50
+ : data;
51
+ console.log("conn.transport?.write:", conn.transport?.write, bytes);
52
+ await conn.transport?.write(bytes);
53
+ };
54
+ const readLoop = async (uuid, transport) => {
55
+ try {
56
+ while (true) {
57
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
58
+ if (!transport?.rawRead || !conn) {
59
+ console.log("Transport failed to load!", conn, conn?.transport, transport?.rawRead);
60
+ break;
61
+ }
62
+ ;
63
+ const reader = transport?.rawRead();
64
+ const { value, done } = await reader.next();
65
+ if (done || !value)
66
+ break;
67
+ conn.onReceive?.(value);
68
+ }
69
+ }
70
+ catch (e) {
71
+ base.appendLog(uuid, {
72
+ level: 0,
73
+ message: `[!] Read loop error: ${e}`
74
+ });
75
+ await disconnect(uuid);
76
+ }
77
+ finally {
78
+ base.updateConnection(uuid, (c) => ({
79
+ ...c,
80
+ transport: null,
81
+ isConnected: false,
82
+ isConnecting: false,
83
+ autoConnect: false,
84
+ }));
85
+ base.appendLog(uuid, {
86
+ level: 0,
87
+ message: "[!] Serial disconnected"
88
+ });
89
+ }
90
+ };
91
+ const restartDevice = async (uuid, default_transport) => {
92
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
93
+ const transport = default_transport ?? conn?.transport;
94
+ if (transport) {
95
+ await transport.setRTS(false);
96
+ await new Promise((resolve) => setTimeout(resolve, 100));
97
+ await transport.setRTS(true);
98
+ await new Promise((resolve) => setTimeout(resolve, 100));
99
+ }
100
+ else {
101
+ console.log("No transport yet");
102
+ }
103
+ };
104
+ const connect = async (uuid, baudrate, restart_on_connect = true) => {
105
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
106
+ if (!conn?.port)
107
+ return;
108
+ if (!conn?.transport) {
109
+ await disconnect(uuid);
110
+ }
111
+ ;
112
+ base.updateConnection(uuid, (c) => ({ ...c, isConnecting: true }));
113
+ const use_baudrate = baudrate ?? conn.baudrate ?? 115200;
114
+ const transport = new Transport(conn.port, false, false);
115
+ try {
116
+ await transport.connect(use_baudrate);
117
+ if (restart_on_connect) {
118
+ await restartDevice(uuid, transport);
119
+ }
120
+ ;
121
+ base.updateConnection(uuid, (c) => ({
122
+ ...c,
123
+ transport,
124
+ baudrate: use_baudrate,
125
+ isConnected: true,
126
+ isConnecting: false,
127
+ }));
128
+ base.appendLog(uuid, {
129
+ level: 2,
130
+ message: "[✓] Serial connected"
131
+ });
132
+ await conn.onConnect?.();
133
+ await readLoop(uuid, transport);
134
+ }
135
+ catch (err) {
136
+ base.updateConnection(uuid, (c) => ({
137
+ ...c,
138
+ isConnected: false,
139
+ isConnecting: false
140
+ }));
141
+ base.appendLog(uuid, {
142
+ level: 0,
143
+ message: `[x] Serial connection error: ${err?.message || "Unknown error"}`
144
+ });
145
+ await disconnect(uuid);
146
+ }
147
+ };
148
+ const disconnect = async (uuid, timeout = 2000) => {
149
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
150
+ if (conn?.transport) {
151
+ try {
152
+ // Attempt disconnect, but don’t hang if the port is crashed
153
+ await Promise.race([
154
+ conn.transport.disconnect(),
155
+ new Promise((resolve) => setTimeout(resolve, timeout)),
156
+ ]);
157
+ }
158
+ catch (e) {
159
+ console.warn(`[${uuid}] Serial Disconnect error:`, e);
160
+ }
161
+ }
162
+ // Always clear the transport and reset connection state
163
+ base.updateConnection(uuid, (c) => ({
164
+ ...c,
165
+ transport: null,
166
+ isConnected: false,
167
+ isConnecting: false,
168
+ autoConnect: false,
169
+ }));
170
+ await conn?.onDisconnect?.();
171
+ };
172
+ const addConnection = async ({ uuid, propCreator }) => {
173
+ const port = await navigator.serial.requestPort({
174
+ filters: [{ usbVendorId: 0x303a }]
175
+ });
176
+ return await base.addConnection({
177
+ uuid,
178
+ propCreator: (id) => {
179
+ const props = propCreator?.(id);
180
+ return {
181
+ send: props?.send || ((d) => defaultSend(id, d)),
182
+ onReceive: props?.onReceive || ((d) => defaultOnReceive(id, d)),
183
+ port,
184
+ ...props
185
+ };
186
+ }
187
+ });
188
+ };
189
+ const removeConnection = async (uuid) => {
190
+ try {
191
+ await disconnect(uuid);
192
+ }
193
+ catch (e) { }
194
+ ;
195
+ base.removeConnection(uuid);
196
+ };
197
+ // This function now handles an entire device flashing session
198
+ const handleFlashFirmware = async (uuid, assetsToFlash) => {
199
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
200
+ if (!conn || !conn.port || assetsToFlash.length === 0)
201
+ return;
202
+ await disconnect(uuid);
203
+ base.updateConnection(uuid, (c) => ({ ...c, isFlashing: true, flashProgress: 0, flashError: undefined }));
204
+ try {
205
+ // --- Connect ONCE ---
206
+ const transport = new Transport(conn.port, true);
207
+ const esploader = new ESPLoader({
208
+ transport,
209
+ baudrate: 921600,
210
+ enableTracing: false
211
+ });
212
+ try {
213
+ await esploader.main();
214
+ }
215
+ catch (e) {
216
+ console.log("failed to esploader.main()", e);
217
+ return;
218
+ }
219
+ ;
220
+ // --- Prepare an ARRAY of files for the library ---
221
+ const fileArray = await Promise.all(assetsToFlash.map(async ({ blob, address }) => {
222
+ const arrayBuffer = await blob.arrayBuffer();
223
+ const binaryString = Array.from(new Uint8Array(arrayBuffer))
224
+ .map((b) => String.fromCharCode(b))
225
+ .join("");
226
+ return { data: binaryString, address };
227
+ }));
228
+ const flashOptions = {
229
+ fileArray, // Pass the whole array here
230
+ flashSize: "keep",
231
+ flashMode: "qio",
232
+ flashFreq: "80m",
233
+ eraseAll: fileArray.length > 1, // Writing more than 1 thing, so likely writing partitions.
234
+ compress: true,
235
+ reportProgress: (fileIndex, written, total) => {
236
+ // You can enhance progress reporting to show which file is being flashed
237
+ const progress = (written / total) * 100;
238
+ console.log(`Flashing file ${fileIndex + 1}/${fileArray.length}: ${progress.toFixed(1)}%`);
239
+ base.updateConnection(uuid, (c) => ({ ...c, flashProgress: progress }));
240
+ },
241
+ };
242
+ // --- Call writeFlash ONCE with all files ---
243
+ try {
244
+ base.updateConnection(uuid, (c) => ({ ...c, flashProgress: -1 }));
245
+ await esploader.writeFlash(flashOptions);
246
+ }
247
+ catch (e) {
248
+ console.log("failed to esploader.writeFlash", e);
249
+ }
250
+ ;
251
+ // --- Disconnect ---
252
+ await esploader.after();
253
+ try {
254
+ await transport.disconnect();
255
+ }
256
+ catch (e) {
257
+ console.log("failed to transport.disconnect", e);
258
+ await conn.port?.readable?.cancel();
259
+ await conn.port?.writable?.close();
260
+ await conn.port?.close();
261
+ }
262
+ base.updateConnection(uuid, (c) => ({ ...c, isFlashing: false, flashProgress: 100 }));
263
+ }
264
+ catch (e) {
265
+ console.error(`[${uuid}] Flashing failed:`, e);
266
+ base.updateConnection(uuid, (c) => ({
267
+ ...c,
268
+ isFlashing: false,
269
+ flashError: e?.message ?? "Unknown error",
270
+ }));
271
+ }
272
+ };
273
+ const reconnectAll = async (...connectionProps) => {
274
+ const connections = [...base.connectionsRef.current]; // snapshot first
275
+ await Promise.all(connections.map(async (c) => {
276
+ await disconnect(c.uuid);
277
+ await new Promise((res) => setTimeout(res, 250));
278
+ return connect(c.uuid, ...connectionProps);
279
+ }));
280
+ };
281
+ return {
282
+ ...base,
283
+ addConnection,
284
+ removeConnection,
285
+ connect,
286
+ disconnect,
287
+ handleFlashFirmware,
288
+ reconnectAll
289
+ };
290
+ }
@@ -0,0 +1,24 @@
1
+ import { DeviceConnectionState, AddConnectionProps } from "@/base/device-whisperer.js";
2
+ export type WebsocketConnectionState = DeviceConnectionState & {
3
+ ws?: WebSocket;
4
+ };
5
+ export type DeviceObjectResponse = {
6
+ id: string;
7
+ deviceState: string;
8
+ uiState: string;
9
+ deviceConnectedTime: Date | null;
10
+ deviceLastCommTime: Date | null;
11
+ };
12
+ export declare function WebsocketMultiDeviceWhisperer<AppOrMessageLayer extends WebsocketConnectionState>(server_url: string, server_port: number, { ...props }?: {}): {
13
+ addConnection: ({ uuid, propCreator }: AddConnectionProps<AppOrMessageLayer>) => Promise<string>;
14
+ removeConnection: (uuid: string) => Promise<void>;
15
+ connect: (uuid: string, attempt?: number) => Promise<void>;
16
+ disconnect: (uuid: string) => Promise<void>;
17
+ checkForNewDevices: () => Promise<DeviceObjectResponse[]>;
18
+ reconnectAll: () => Promise<void>;
19
+ connections: AppOrMessageLayer[];
20
+ connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
21
+ updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
22
+ updateConnectionName: (uuid: string, name: string) => void;
23
+ appendLog: (uuid: string, log: import("@/base/device-whisperer.js").LogLine) => void;
24
+ };
@@ -0,0 +1,193 @@
1
+ import { MultiDeviceWhisperer } from "@/base/device-whisperer.js";
2
+ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...props } = {}) {
3
+ const defaultOnReceive = (uuid, data) => {
4
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
5
+ if (!conn)
6
+ return;
7
+ const decoder = new TextDecoder();
8
+ let bytes;
9
+ if (typeof data === "string") {
10
+ bytes = new TextEncoder().encode(data);
11
+ }
12
+ else if (data instanceof ArrayBuffer) {
13
+ bytes = new Uint8Array(data);
14
+ }
15
+ else {
16
+ bytes = data;
17
+ }
18
+ const asText = decoder.decode(bytes);
19
+ const combined = conn.readBufferLeftover + asText;
20
+ const lines = combined.split("\n");
21
+ base.updateConnection(uuid, (c) => ({
22
+ ...c,
23
+ readBufferLeftover: lines.pop() || ""
24
+ }));
25
+ for (const line of lines) {
26
+ const trimmed = line.trim();
27
+ if (trimmed) {
28
+ base.appendLog(uuid, {
29
+ level: 2,
30
+ message: trimmed,
31
+ });
32
+ }
33
+ }
34
+ };
35
+ const defaultSend = async (uuid, data) => {
36
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
37
+ if (!conn || !conn.ws)
38
+ return;
39
+ const asString = typeof data === "string"
40
+ ? data
41
+ : btoa(String.fromCharCode(...data));
42
+ base.appendLog(uuid, {
43
+ level: 3,
44
+ message: asString,
45
+ });
46
+ conn.ws.send(data);
47
+ };
48
+ const connect = async (uuid, attempt = 0) => {
49
+ const MAX_RETRIES = 5;
50
+ const RETRY_DELAY_MS = 2000; // 2 seconds
51
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
52
+ if (!conn)
53
+ return;
54
+ if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
55
+ console.log(`[client] Closing existing WS before reconnect for ${uuid}`);
56
+ conn.ws.close();
57
+ }
58
+ base.updateConnection(uuid, (c) => ({ ...c, isConnecting: true, autoConnect: true }));
59
+ try {
60
+ const pre = server_port !== 443 ? "ws" : "wss";
61
+ const ws = new WebSocket(`${pre}://${server_url}:${server_port}/ui/${uuid}`);
62
+ ws.binaryType = "arraybuffer";
63
+ ws.onopen = () => {
64
+ base.updateConnection(uuid, (c) => ({
65
+ ...c,
66
+ ws,
67
+ isConnected: true,
68
+ isConnecting: false,
69
+ lastAttempt: attempt
70
+ }));
71
+ base.appendLog(uuid, {
72
+ level: 2,
73
+ message: "[✓] WebSocket connected"
74
+ });
75
+ conn?.onConnect?.();
76
+ };
77
+ ws.onmessage = (event) => {
78
+ let data;
79
+ if (typeof event.data === "string")
80
+ data = event.data;
81
+ else if (event.data instanceof ArrayBuffer)
82
+ data = new Uint8Array(event.data);
83
+ else
84
+ data = event.data;
85
+ conn.onReceive?.(data);
86
+ };
87
+ ws.onerror = (err) => {
88
+ base.appendLog(uuid, {
89
+ level: 0,
90
+ message: `[x] WS error: ${err}`
91
+ });
92
+ };
93
+ ws.onclose = async () => {
94
+ base.updateConnection(uuid, (c) => ({
95
+ ...c,
96
+ isConnected: false,
97
+ isConnecting: false,
98
+ ws: undefined
99
+ }));
100
+ base.appendLog(uuid, { level: 0, message: "[!] WS disconnected" });
101
+ const updated_conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
102
+ if (!updated_conn) {
103
+ base.appendLog(uuid, { level: 0, message: "[!] Connection lost!" });
104
+ return;
105
+ }
106
+ // Auto reconnect if enabled
107
+ if (updated_conn.autoConnect && attempt < MAX_RETRIES) {
108
+ base.appendLog(uuid, { level: 2, message: `[~] Reconnecting in ${RETRY_DELAY_MS}ms... (attempt ${attempt + 1})` });
109
+ setTimeout(() => connect(uuid, attempt + 1), RETRY_DELAY_MS);
110
+ }
111
+ };
112
+ }
113
+ catch (err) {
114
+ base.updateConnection(uuid, (c) => ({
115
+ ...c,
116
+ isConnected: false,
117
+ isConnecting: false,
118
+ logs: [
119
+ ...c.logs,
120
+ { level: 0, message: `[x] WS connection error: ${err?.message || "Unknown error"}` }
121
+ ]
122
+ }));
123
+ await disconnect(uuid);
124
+ }
125
+ };
126
+ const disconnect = async (uuid) => {
127
+ const conn = base.connectionsRef.current.find((c) => c.uuid === uuid);
128
+ if (!conn?.ws)
129
+ return;
130
+ base.updateConnection(uuid, (c) => ({
131
+ ...c,
132
+ isConnected: false,
133
+ isConnecting: false,
134
+ autoConnect: false,
135
+ ws: undefined,
136
+ readBufferLeftover: ""
137
+ }));
138
+ conn.ws.close();
139
+ await conn?.onDisconnect?.();
140
+ };
141
+ const base = MultiDeviceWhisperer(props);
142
+ const addConnection = async ({ uuid, propCreator }) => {
143
+ return await base.addConnection({
144
+ uuid,
145
+ propCreator: (id) => {
146
+ const props = propCreator?.(id);
147
+ return {
148
+ send: props?.send || ((d) => defaultSend(id, d)),
149
+ onReceive: props?.onReceive || ((d) => defaultOnReceive(id, d)),
150
+ ...props
151
+ };
152
+ }
153
+ });
154
+ };
155
+ const removeConnection = async (uuid) => {
156
+ await disconnect(uuid);
157
+ base.removeConnection(uuid);
158
+ };
159
+ const checkForNewDevices = async () => {
160
+ try {
161
+ const url = server_port !== 443
162
+ ? `http://${server_url}:${server_port}`
163
+ : `https://${server_url}`;
164
+ const response = await fetch(`${url}/devices`);
165
+ if (!response.ok) {
166
+ throw new Error(`HTTP error! status: ${response.status}`);
167
+ }
168
+ return (await response.json());
169
+ }
170
+ catch (error) {
171
+ console.error("Failed to fetch devices:", error);
172
+ return [];
173
+ }
174
+ };
175
+ const reconnectAll = async () => {
176
+ for (const c of base.connectionsRef.current) {
177
+ await disconnect(c.uuid);
178
+ await new Promise((res) => setTimeout(res, 250));
179
+ }
180
+ for (const c of base.connectionsRef.current) {
181
+ await connect(c.uuid);
182
+ }
183
+ };
184
+ return {
185
+ ...base,
186
+ addConnection,
187
+ removeConnection,
188
+ connect,
189
+ disconnect,
190
+ checkForNewDevices,
191
+ reconnectAll
192
+ };
193
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "ota-hub-reactjs",
3
+ "version": "0.0.1",
4
+ "description": "ReactJS tools for building web apps to flash MCU devices such as esp32, brought to you by OTA Hub.",
5
+ "main": "dist/index.js",
6
+ "type": "module",
7
+ "module": "dist/index.esm.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Hard-Stuff/OTA-Hub-reactjs.git"
15
+ },
16
+ "keywords": [
17
+ "esp32",
18
+ "ota",
19
+ "react",
20
+ "iot",
21
+ "firmware"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "bugs": {
26
+ "url": "https://github.com/Hard-Stuff/OTA-Hub-reactjs/issues"
27
+ },
28
+ "homepage": "https://github.com/Hard-Stuff/OTA-Hub-reactjs#readme",
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "prepublishOnly": "npm run build"
32
+ },
33
+ "dependencies": {
34
+ "esptool-js": "^0.5.5",
35
+ "mqtt": "^5.14.0",
36
+ "unique-names-generator": "^4.7.1",
37
+ "uuid": "^11.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18.0.0",
41
+ "react-dom": ">=18.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "^19.0.10",
45
+ "@types/react-dom": "^19.0.4",
46
+ "react": "^19.1.1",
47
+ "react-dom": "^19.1.1",
48
+ "ts-proto": "^2.7.5",
49
+ "typescript": "^5.3.0",
50
+ "@types/w3c-web-serial": "^1.0.8"
51
+
52
+ }
53
+ }