ota-hub-reactjs 0.0.7 → 0.0.9
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/README.md +103 -24
- package/dist/base/device-whisperer.d.ts +6 -1
- package/dist/base/device-whisperer.js +8 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/message_layers/protobuf-wrapper.d.ts +3 -0
- package/dist/message_layers/protobuf-wrapper.js +1 -1
- package/dist/transport_layers/esp32-device-whisperer.d.ts +31 -0
- package/dist/transport_layers/esp32-device-whisperer.js +89 -0
- package/dist/transport_layers/mqtt-device-whisperer.d.ts +31 -0
- package/dist/transport_layers/mqtt-device-whisperer.js +239 -0
- package/dist/transport_layers/serial-device-whisperer.d.ts +5 -13
- package/dist/transport_layers/serial-device-whisperer.js +93 -102
- package/dist/transport_layers/websocket-device-whisperer.d.ts +7 -1
- package/dist/transport_layers/websocket-device-whisperer.js +11 -7
- package/package.json +52 -53
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# OTA Hub ReactJS
|
|
2
2
|
|
|
3
|
-
**ReactJS tools for interacting with
|
|
3
|
+
**ReactJS tools for interacting with Microprocessors and IoT devices (such as ESP32) over various transports.**
|
|
4
4
|
|
|
5
5
|
`ota-hub-reactjs` provides a set of React-friendly utilities to:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
7
|
+
- Read from multiple MCUs **in parallel** over a range of transports, including:
|
|
8
|
+
- Serial (USB)
|
|
9
|
+
- MQTT (e.g. with AWS IoT Core)
|
|
10
|
+
- Websockets
|
|
11
|
+
- Flash firmware to ESP32 directly in browser
|
|
12
|
+
- Handle Protobuf and other message layer wrappers
|
|
12
13
|
|
|
13
14
|
## Installation
|
|
14
15
|
|
|
@@ -18,7 +19,7 @@ npm install ota-hub-reactjs
|
|
|
18
19
|
yarn add ota-hub-reactjs
|
|
19
20
|
```
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
This is a react lib, so you will need the peer dependencies:
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
25
|
npm install react react-dom
|
|
@@ -28,9 +29,9 @@ npm install react react-dom
|
|
|
28
29
|
|
|
29
30
|
## Transport layers
|
|
30
31
|
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
32
|
+
- All transport layers are designed to support multiple connections (thus devices) in parallel
|
|
33
|
+
- You can mix and match tranport layers within your applications as separate dedicated `Device Whisperers`
|
|
34
|
+
- All ConnectionStates are extendible, e.g.
|
|
34
35
|
```ts
|
|
35
36
|
export type EnhancedSerialConnectionState = SerialConnectionState & {
|
|
36
37
|
dataPoints: DataPoint[];
|
|
@@ -43,7 +44,8 @@ npm install react react-dom
|
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
### Serial Communication
|
|
46
|
-
|
|
47
|
+
|
|
48
|
+
Read and write to multiple MCUs in parallel over multiple serial connections.
|
|
47
49
|
|
|
48
50
|
```tsx
|
|
49
51
|
import { SerialMultiDeviceWhisperer, SerialConnectionState } from "ota-hub-reactjs";
|
|
@@ -58,45 +60,122 @@ serialDeviceWhisperer.addConnection(); // Web browser will prompt for which Seri
|
|
|
58
60
|
}/>
|
|
59
61
|
```
|
|
60
62
|
|
|
63
|
+
## MQTT AWS IoT Core Streaming
|
|
64
|
+
|
|
65
|
+
Connect to MCUs that expose WebSocket interfaces for live data streaming.<br />
|
|
66
|
+
_Currently this is for a server that allows multiple devices as clients to connect through, rather than one device as a server itself_
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { MQTTMultiDeviceWhisperer } from "ota-hub-reactjs";
|
|
70
|
+
|
|
71
|
+
import { Sha256 } from "@aws-crypto/sha256-js";
|
|
72
|
+
import { SignatureV4 } from "@aws-sdk/signature-v4";
|
|
73
|
+
import { HttpRequest } from "@aws-sdk/protocol-http";
|
|
74
|
+
|
|
75
|
+
// Get your AWS IoT Core Signed URL
|
|
76
|
+
const createAwsIotWssUrl = async () => {
|
|
77
|
+
const endpoint = process.env.IOT_ENDPOINT!; // Your IoT Core Endpoint
|
|
78
|
+
const region = process.env.IOT_REGION!; // Your IoT Core Region
|
|
79
|
+
|
|
80
|
+
const signer = new SignatureV4({
|
|
81
|
+
service: "iotdevicegateway",
|
|
82
|
+
region,
|
|
83
|
+
credentials: {
|
|
84
|
+
accessKeyId: process.env.IOT_ACCESS_KEY_ID!,
|
|
85
|
+
secretAccessKey: process.env.IOT_SECRET_ACCESS_KEY!,
|
|
86
|
+
},
|
|
87
|
+
sha256: Sha256,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const request = new HttpRequest({
|
|
91
|
+
protocol: "wss:",
|
|
92
|
+
hostname: endpoint,
|
|
93
|
+
method: "GET",
|
|
94
|
+
path: "/mqtt",
|
|
95
|
+
headers: { host: endpoint },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const signed = await signer.presign(request, { expiresIn: 60 });
|
|
99
|
+
const qs = new URLSearchParams(signed.query as Record<string, string>).toString();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
url: `${signed.protocol}//${signed.hostname}${signed.path}?${qs}`,
|
|
103
|
+
clientId: `web-${Date.now()}`,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const { url, clientId } = await createAwsIotWssUrl(); // Best done on a backend
|
|
108
|
+
|
|
109
|
+
const whisperer = MQTTMultiDeviceWhisperer<EnhancedMQTTConnectionState>({
|
|
110
|
+
serverUrl: url,
|
|
111
|
+
clientId: clientId,
|
|
112
|
+
serverPort: 443, // Mosquito etc. are often 8883. IoT Core is MQTT over WS, so 443.
|
|
113
|
+
subTopicFromUuid: (uuid) => `${uuid}/sub_topic`,
|
|
114
|
+
pubTopicFromUuid: (uuid) => `${uuid}/pub_topic`,
|
|
115
|
+
uuidFromMessage: (topic, _p) => topic.split("/")[0],
|
|
116
|
+
autoConnect: false,
|
|
117
|
+
connectOn: true, // connect to MQTT boker on .. can also have this state managed, i.e. when a user clicks a button
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Then as normal:
|
|
121
|
+
const uuid = "Thingname on AWS"
|
|
122
|
+
await whisperer.addConnection({ uuid });
|
|
123
|
+
const given_connection = await whisperer.getConnection(uuid)
|
|
124
|
+
given_connection.send("hello there"); // or stringified JSON or bytes. Sends on default pubTopic (from UUID)
|
|
125
|
+
```
|
|
126
|
+
|
|
61
127
|
## WebSocket Streaming
|
|
128
|
+
|
|
62
129
|
Connect to MCUs that expose WebSocket interfaces for live data streaming.<br />
|
|
63
130
|
_Currently this is for a server that allows multiple devices as clients to connect through, rather than one device as a server itself_
|
|
64
131
|
|
|
65
132
|
```tsx
|
|
66
133
|
import { WebsocketMultiDeviceWhisperer } from "ota-hub-reactjs";
|
|
67
134
|
|
|
68
|
-
const websocketDeviceWhisperer = new WebsocketMultiDeviceWhisperer<EnhancedWebsocketConnectionState>(
|
|
135
|
+
const websocketDeviceWhisperer = new WebsocketMultiDeviceWhisperer<EnhancedWebsocketConnectionState>(
|
|
136
|
+
"ws://192.168.1.100:8080"
|
|
137
|
+
);
|
|
69
138
|
|
|
70
139
|
// then as Serial
|
|
71
140
|
```
|
|
72
141
|
|
|
73
142
|
## Flash Firmware
|
|
74
|
-
|
|
143
|
+
|
|
144
|
+
**Currently Firmware flashing is only available on Serial connections.** Flash multiple MCUs with firmware images using esptool-js under the hood.
|
|
75
145
|
|
|
76
146
|
```ts
|
|
77
147
|
await Promise.all(
|
|
78
|
-
serialDeviceWhisperer.connections
|
|
79
|
-
|
|
148
|
+
serialDeviceWhisperer.connections.map((c) =>
|
|
149
|
+
serialDeviceWhisperer.handleFlashFirmware({ uuid: c.uuid, firmwareBlob: blobToFlash! })
|
|
150
|
+
)
|
|
80
151
|
);
|
|
81
152
|
```
|
|
82
153
|
|
|
83
154
|
# Message Layer Wrappers
|
|
84
|
-
|
|
155
|
+
|
|
156
|
+
Supports Protobuf and other custom message layers for structured communication. **Experimental feature, and those with familiarity with Protobuf might want to handle this themselves and call `.send` and `.onReceive` themselves.**
|
|
85
157
|
|
|
86
158
|
```ts
|
|
87
159
|
import { ProtobufMultiDeviceWhisperer } from "ota-hub-reactjs";
|
|
88
160
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
161
|
+
const protobufSerialDeviceWhisperer = ProtobufMultiDeviceWhisperer<
|
|
162
|
+
EnhancedSerialConnectionState,
|
|
163
|
+
TXType,
|
|
164
|
+
TXFromESP,
|
|
165
|
+
RXToESP
|
|
166
|
+
>({
|
|
167
|
+
transportLayer: serialDeviceWhisperer,
|
|
168
|
+
encodeRX: (msg) => RXToESP.encode(msg).finish(),
|
|
169
|
+
decodeTX: (bytes) => TXFromESP.decode(bytes),
|
|
170
|
+
messageTypeField: "txType",
|
|
171
|
+
rxTopicHandlerMap: logHandlerMap,
|
|
96
172
|
});
|
|
97
173
|
```
|
|
174
|
+
|
|
98
175
|
# Contributing
|
|
176
|
+
|
|
99
177
|
Contributions are welcome! Please submit issues or pull requests via the GitHub repository.
|
|
100
178
|
|
|
101
179
|
# License
|
|
102
|
-
|
|
180
|
+
|
|
181
|
+
MIT License © 2026 OTA-Hub
|
|
@@ -14,7 +14,7 @@ export type DeviceConnectionState = {
|
|
|
14
14
|
deviceMac?: string;
|
|
15
15
|
name: string;
|
|
16
16
|
send: (data: string | Uint8Array) => void | Promise<void>;
|
|
17
|
-
onReceive?: (data: string | Uint8Array) =>
|
|
17
|
+
onReceive?: (data: string | Uint8Array) => void;
|
|
18
18
|
onConnect?: () => void | Promise<void>;
|
|
19
19
|
onDisconnect?: () => void | Promise<void>;
|
|
20
20
|
autoConnect: boolean;
|
|
@@ -22,10 +22,12 @@ export type DeviceConnectionState = {
|
|
|
22
22
|
isConnecting: boolean;
|
|
23
23
|
logs: LogLine[];
|
|
24
24
|
readBufferLeftover: string;
|
|
25
|
+
readBufferLeftoverAsBytes: Uint8Array;
|
|
25
26
|
};
|
|
26
27
|
export declare function createDefaultInitialDeviceState<T extends DeviceConnectionState>(uuid: string, props?: any): T;
|
|
27
28
|
export type DeviceWhispererProps<T extends DeviceConnectionState> = {
|
|
28
29
|
createInitialConnectionState?: (uuid: string) => T;
|
|
30
|
+
connectOn?: boolean;
|
|
29
31
|
};
|
|
30
32
|
export declare function MultiDeviceWhisperer<T extends DeviceConnectionState>({ createInitialConnectionState, }?: DeviceWhispererProps<T>): {
|
|
31
33
|
connections: T[];
|
|
@@ -37,6 +39,9 @@ export declare function MultiDeviceWhisperer<T extends DeviceConnectionState>({
|
|
|
37
39
|
updateConnection: (uuid: string, updater: (c: T) => T) => void;
|
|
38
40
|
reconnectAll: () => void;
|
|
39
41
|
updateConnectionName: (uuid: string, name: string) => void;
|
|
42
|
+
getConnection: (uuid: string) => T | undefined;
|
|
40
43
|
appendLog: (uuid: string, log: LogLine) => void;
|
|
44
|
+
isReady: boolean;
|
|
45
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
41
46
|
};
|
|
42
47
|
export {};
|
|
@@ -11,6 +11,7 @@ export function createDefaultInitialDeviceState(uuid, props) {
|
|
|
11
11
|
isConnecting: false,
|
|
12
12
|
logs: [],
|
|
13
13
|
readBufferLeftover: "",
|
|
14
|
+
readBufferLeftoverAsBytes: new Uint8Array([]),
|
|
14
15
|
...props
|
|
15
16
|
};
|
|
16
17
|
}
|
|
@@ -20,6 +21,8 @@ export function createDefaultInitialDeviceState(uuid, props) {
|
|
|
20
21
|
export function MultiDeviceWhisperer({ createInitialConnectionState = createDefaultInitialDeviceState, } = {}) {
|
|
21
22
|
const [connections, setConnections] = useState([]);
|
|
22
23
|
const connectionsRef = useRef(connections);
|
|
24
|
+
const [isReady, setIsReady] = useState(false);
|
|
25
|
+
const getConnection = (uuid) => connectionsRef.current.find(c => c.uuid === uuid);
|
|
23
26
|
const updateConnection = (uuid, updater) => {
|
|
24
27
|
setConnections(prev => {
|
|
25
28
|
const updated = prev.map((c) => c.uuid === uuid ? updater(c) : c);
|
|
@@ -48,7 +51,7 @@ export function MultiDeviceWhisperer({ createInitialConnectionState = createDefa
|
|
|
48
51
|
};
|
|
49
52
|
connectionsRef.current = [...connectionsRef.current, newConnection];
|
|
50
53
|
setConnections(prev => [...prev, newConnection]);
|
|
51
|
-
const anyUpdatedConnection =
|
|
54
|
+
const anyUpdatedConnection = getConnection(uuid);
|
|
52
55
|
if (!anyUpdatedConnection) {
|
|
53
56
|
return "";
|
|
54
57
|
}
|
|
@@ -70,6 +73,9 @@ export function MultiDeviceWhisperer({ createInitialConnectionState = createDefa
|
|
|
70
73
|
updateConnection,
|
|
71
74
|
reconnectAll: () => { },
|
|
72
75
|
updateConnectionName,
|
|
73
|
-
|
|
76
|
+
getConnection,
|
|
77
|
+
appendLog,
|
|
78
|
+
isReady,
|
|
79
|
+
setIsReady
|
|
74
80
|
};
|
|
75
81
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,5 @@ export * from "./base/device-whisperer.js";
|
|
|
2
2
|
export * from "./message_layers/protobuf-wrapper.js";
|
|
3
3
|
export * from "./transport_layers/serial-device-whisperer.js";
|
|
4
4
|
export * from "./transport_layers/websocket-device-whisperer.js";
|
|
5
|
+
export * from "./transport_layers/mqtt-device-whisperer.js";
|
|
5
6
|
export * from "./types/builds.js";
|
package/dist/index.js
CHANGED
|
@@ -5,5 +5,6 @@ export * from "./message_layers/protobuf-wrapper.js";
|
|
|
5
5
|
// Transport layers
|
|
6
6
|
export * from "./transport_layers/serial-device-whisperer.js";
|
|
7
7
|
export * from "./transport_layers/websocket-device-whisperer.js";
|
|
8
|
+
export * from "./transport_layers/mqtt-device-whisperer.js";
|
|
8
9
|
// Specific types
|
|
9
10
|
export * from "./types/builds.js";
|
|
@@ -27,5 +27,8 @@ export declare function ProtobufMultiDeviceWhisperer<AppLayer extends DeviceConn
|
|
|
27
27
|
updateConnection: (uuid: string, updater: (c: AppLayer) => AppLayer) => void;
|
|
28
28
|
reconnectAll: () => void;
|
|
29
29
|
updateConnectionName: (uuid: string, name: string) => void;
|
|
30
|
+
getConnection: (uuid: string) => AppLayer | undefined;
|
|
30
31
|
appendLog: (uuid: string, log: import("../base/device-whisperer.js").LogLine) => void;
|
|
32
|
+
isReady: boolean;
|
|
33
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
31
34
|
};
|
|
@@ -48,7 +48,7 @@ export function ProtobufMultiDeviceWhisperer({ transportLayer, encodeRX, decodeT
|
|
|
48
48
|
const sendProtobuf = (uuid, message) => {
|
|
49
49
|
const encoded = encodeRX(message);
|
|
50
50
|
const wrapped = wrapLengthPrefixed(encoded);
|
|
51
|
-
const conn = transportLayer.
|
|
51
|
+
const conn = transportLayer.getConnection(uuid);
|
|
52
52
|
console.log("Sending Protobuf:", message, "bytes: ", [...wrapped]);
|
|
53
53
|
conn?.send?.(wrapped);
|
|
54
54
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { FlashOptions } from "esptool-js";
|
|
2
|
+
import { SerialConnectionState } from "./serial-device-whisperer.js";
|
|
3
|
+
export type ESP32ConnectionState = SerialConnectionState & {
|
|
4
|
+
isFlashing: boolean;
|
|
5
|
+
flashProgress: number;
|
|
6
|
+
flashError: string;
|
|
7
|
+
};
|
|
8
|
+
export type FlashFirmwareProps = {
|
|
9
|
+
uuid: string;
|
|
10
|
+
firmwareBlob?: Blob;
|
|
11
|
+
fileArray?: FlashOptions["fileArray"];
|
|
12
|
+
};
|
|
13
|
+
export declare function ESP32MultiDeviceWhisperer<AppOrMessageLayer extends ESP32ConnectionState>({ ...props }?: {}): {
|
|
14
|
+
handleFlashFirmware: (uuid: string, assetsToFlash: {
|
|
15
|
+
blob: Blob;
|
|
16
|
+
address: number;
|
|
17
|
+
}[]) => Promise<void>;
|
|
18
|
+
addConnection: ({ uuid, propCreator }: import("../index.js").AddConnectionProps<AppOrMessageLayer>) => Promise<string>;
|
|
19
|
+
removeConnection: (uuid: string) => Promise<void>;
|
|
20
|
+
connect: (uuid: string, baudrate?: number, restart_on_connect?: boolean) => Promise<void>;
|
|
21
|
+
disconnect: (uuid: string, timeout?: number) => Promise<void>;
|
|
22
|
+
reconnectAll: (...connectionProps: any) => Promise<void>;
|
|
23
|
+
connections: AppOrMessageLayer[];
|
|
24
|
+
connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
|
|
25
|
+
updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
|
|
26
|
+
updateConnectionName: (uuid: string, name: string) => void;
|
|
27
|
+
getConnection: (uuid: string) => AppOrMessageLayer | undefined;
|
|
28
|
+
appendLog: (uuid: string, log: import("../index.js").LogLine) => void;
|
|
29
|
+
isReady: boolean;
|
|
30
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { ESPLoader, Transport } from "esptool-js";
|
|
3
|
+
import { SerialMultiDeviceWhisperer } from "./serial-device-whisperer.js";
|
|
4
|
+
export function ESP32MultiDeviceWhisperer({ ...props } = {}) {
|
|
5
|
+
const base = SerialMultiDeviceWhisperer(props);
|
|
6
|
+
// This function now handles an entire device flashing session
|
|
7
|
+
const handleFlashFirmware = async (uuid, assetsToFlash) => {
|
|
8
|
+
const conn = base.getConnection(uuid);
|
|
9
|
+
if (!conn || !conn.port || assetsToFlash.length === 0)
|
|
10
|
+
return;
|
|
11
|
+
await base.disconnect(uuid);
|
|
12
|
+
base.updateConnection(uuid, (c) => ({ ...c, isFlashing: true, flashProgress: 0, flashError: undefined }));
|
|
13
|
+
try {
|
|
14
|
+
// --- Connect ONCE ---
|
|
15
|
+
const transport = new Transport(conn.port, true);
|
|
16
|
+
const esploader = new ESPLoader({
|
|
17
|
+
transport,
|
|
18
|
+
baudrate: 921600,
|
|
19
|
+
enableTracing: false
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
await esploader.main();
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
console.log("failed to esploader.main()", e);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
;
|
|
29
|
+
// --- Prepare an ARRAY of files for the library ---
|
|
30
|
+
const fileArray = await Promise.all(assetsToFlash.map(async ({ blob, address }) => {
|
|
31
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
32
|
+
const binaryString = Array.from(new Uint8Array(arrayBuffer))
|
|
33
|
+
.map((b) => String.fromCharCode(b))
|
|
34
|
+
.join("");
|
|
35
|
+
return { data: binaryString, address };
|
|
36
|
+
}));
|
|
37
|
+
const flashOptions = {
|
|
38
|
+
fileArray, // Pass the whole array here
|
|
39
|
+
flashSize: "keep",
|
|
40
|
+
flashMode: "qio",
|
|
41
|
+
flashFreq: "80m",
|
|
42
|
+
eraseAll: fileArray.length > 1, // Writing more than 1 thing, so likely writing partitions.
|
|
43
|
+
compress: true,
|
|
44
|
+
reportProgress: (fileIndex, written, total) => {
|
|
45
|
+
// You can enhance progress reporting to show which file is being flashed
|
|
46
|
+
const progress = (written / total) * 100;
|
|
47
|
+
console.log(`Flashing file ${fileIndex + 1}/${fileArray.length}: ${progress.toFixed(1)}%`);
|
|
48
|
+
base.updateConnection(uuid, (c) => ({ ...c, flashProgress: progress }));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
// --- Call writeFlash ONCE with all files ---
|
|
52
|
+
try {
|
|
53
|
+
base.updateConnection(uuid, (c) => ({ ...c, flashProgress: -1 }));
|
|
54
|
+
await esploader.writeFlash(flashOptions);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
console.log("failed to esploader.writeFlash", e);
|
|
58
|
+
}
|
|
59
|
+
;
|
|
60
|
+
// --- Disconnect ---
|
|
61
|
+
await esploader.after();
|
|
62
|
+
try {
|
|
63
|
+
await transport.disconnect();
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
console.log("failed to transport.disconnect", e);
|
|
67
|
+
await conn.port?.readable?.cancel();
|
|
68
|
+
await conn.port?.writable?.close();
|
|
69
|
+
await conn.port?.close();
|
|
70
|
+
}
|
|
71
|
+
base.updateConnection(uuid, (c) => ({ ...c, isFlashing: false, flashProgress: 100 }));
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
console.error(`[${uuid}] Flashing failed:`, e);
|
|
75
|
+
base.updateConnection(uuid, (c) => ({
|
|
76
|
+
...c,
|
|
77
|
+
isFlashing: false,
|
|
78
|
+
flashError: e?.message ?? "Unknown error",
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
base.setIsReady(true); // Ready on page load by default
|
|
84
|
+
}, []);
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
handleFlashFirmware,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DeviceConnectionState, AddConnectionProps, DeviceWhispererProps } from "../base/device-whisperer.js";
|
|
2
|
+
export type MQTTConnectionState = DeviceConnectionState & {
|
|
3
|
+
pingFunction?: (props?: any) => void;
|
|
4
|
+
touchHeartbeat?: () => void;
|
|
5
|
+
};
|
|
6
|
+
export declare function MQTTMultiDeviceWhisperer<AppOrMessageLayer extends MQTTConnectionState>({ serverUrl, uuidFromMessage, subTopicFromUuid, pubTopicFromUuid, serverPort, clientId, username, password, autoConnect, ...props }: {
|
|
7
|
+
serverUrl: string;
|
|
8
|
+
uuidFromMessage: (topic: string, payload: Buffer<ArrayBufferLike>) => string;
|
|
9
|
+
subTopicFromUuid?: (uuid: string) => string;
|
|
10
|
+
pubTopicFromUuid?: (uuid: string) => string;
|
|
11
|
+
serverPort?: number;
|
|
12
|
+
clientId?: string;
|
|
13
|
+
username?: string;
|
|
14
|
+
password?: string;
|
|
15
|
+
autoConnect?: boolean;
|
|
16
|
+
} & DeviceWhispererProps<AppOrMessageLayer>): {
|
|
17
|
+
addConnection: ({ uuid, propCreator }: AddConnectionProps<AppOrMessageLayer>) => Promise<string | undefined>;
|
|
18
|
+
removeConnection: (uuid: string) => Promise<void>;
|
|
19
|
+
connect: (uuid: string) => Promise<void>;
|
|
20
|
+
disconnect: (uuid: string) => Promise<void>;
|
|
21
|
+
reconnectAll: () => Promise<void>;
|
|
22
|
+
connectToMQTTServer: () => (() => void) | undefined;
|
|
23
|
+
connections: AppOrMessageLayer[];
|
|
24
|
+
connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
|
|
25
|
+
updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
|
|
26
|
+
updateConnectionName: (uuid: string, name: string) => void;
|
|
27
|
+
getConnection: (uuid: string) => AppOrMessageLayer | undefined;
|
|
28
|
+
appendLog: (uuid: string, log: import("../base/device-whisperer.js").LogLine) => void;
|
|
29
|
+
isReady: boolean;
|
|
30
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { MultiDeviceWhisperer } from "../base/device-whisperer.js";
|
|
3
|
+
import mqtt from "mqtt";
|
|
4
|
+
export function MQTTMultiDeviceWhisperer({ serverUrl, uuidFromMessage, subTopicFromUuid = undefined, pubTopicFromUuid = undefined, serverPort = 8883, clientId = undefined, username = undefined, password = undefined, autoConnect = true, ...props }) {
|
|
5
|
+
const base = MultiDeviceWhisperer(props);
|
|
6
|
+
const clientRef = useRef(null);
|
|
7
|
+
const isUnmountedRef = useRef(false);
|
|
8
|
+
const watchdogTimers = useRef({});
|
|
9
|
+
const addingConnections = new Set();
|
|
10
|
+
const connectToMQTTServer = () => {
|
|
11
|
+
isUnmountedRef.current = false;
|
|
12
|
+
if (clientRef.current) {
|
|
13
|
+
if (clientRef.current.connected) {
|
|
14
|
+
base.setIsReady(true);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
clientRef.current.end(true);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const new_client = mqtt.connect(serverUrl, {
|
|
21
|
+
port: serverPort,
|
|
22
|
+
clientId: clientId,
|
|
23
|
+
username,
|
|
24
|
+
password,
|
|
25
|
+
clean: true,
|
|
26
|
+
keepalive: 30,
|
|
27
|
+
reconnectPeriod: 3000,
|
|
28
|
+
});
|
|
29
|
+
new_client.on("connect", () => {
|
|
30
|
+
console.log("MQTT Whisperer Connected");
|
|
31
|
+
base.setIsReady(true);
|
|
32
|
+
});
|
|
33
|
+
new_client.on("reconnect", () => {
|
|
34
|
+
console.log("MQTT Whisperer Reconnecting...");
|
|
35
|
+
base.setIsReady(false);
|
|
36
|
+
});
|
|
37
|
+
new_client.on("close", () => {
|
|
38
|
+
console.log("MQTT Whisperer Closed");
|
|
39
|
+
base.setIsReady(false);
|
|
40
|
+
});
|
|
41
|
+
new_client.on("error", (err) => {
|
|
42
|
+
base.setIsReady(false);
|
|
43
|
+
console.error("MQTT Whisperer Error:", err);
|
|
44
|
+
});
|
|
45
|
+
new_client.on("message", (topic, payload) => {
|
|
46
|
+
if (isUnmountedRef.current)
|
|
47
|
+
return;
|
|
48
|
+
const uuid = uuidFromMessage(topic, payload);
|
|
49
|
+
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
|
|
50
|
+
if (!uuid)
|
|
51
|
+
return;
|
|
52
|
+
const conn = base.getConnection(uuid);
|
|
53
|
+
if (!conn) {
|
|
54
|
+
console.warn("Received message for unknown connection:", uuid);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
conn.touchHeartbeat?.();
|
|
58
|
+
conn.onReceive?.(bytes);
|
|
59
|
+
});
|
|
60
|
+
clientRef.current = new_client;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
console.error("MQTT init failed:", err);
|
|
64
|
+
base.setIsReady(false);
|
|
65
|
+
}
|
|
66
|
+
return () => {
|
|
67
|
+
isUnmountedRef.current = true;
|
|
68
|
+
base.setIsReady(false);
|
|
69
|
+
Object.keys(watchdogTimers.current).forEach((uuid) => {
|
|
70
|
+
const timers = watchdogTimers.current[uuid];
|
|
71
|
+
if (timers) {
|
|
72
|
+
clearTimeout(timers.ping);
|
|
73
|
+
clearTimeout(timers.warn);
|
|
74
|
+
clearTimeout(timers.fail);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
watchdogTimers.current = {};
|
|
78
|
+
if (clientRef.current) {
|
|
79
|
+
clientRef.current.removeAllListeners();
|
|
80
|
+
clientRef.current.end(true);
|
|
81
|
+
}
|
|
82
|
+
clientRef.current = null;
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const connect = async (uuid) => {
|
|
86
|
+
const conn = base.getConnection(uuid);
|
|
87
|
+
if (!clientRef.current || isUnmountedRef.current || !conn)
|
|
88
|
+
return;
|
|
89
|
+
const topic = subTopicFromUuid?.(uuid) ?? uuid;
|
|
90
|
+
if (clientRef.current.connected && !clientRef.current.disconnecting) {
|
|
91
|
+
clientRef.current.subscribe(topic, { qos: 1 }, async (err) => {
|
|
92
|
+
if (err)
|
|
93
|
+
console.error("Subscribe failed:", err);
|
|
94
|
+
else {
|
|
95
|
+
console.log("MQTT Subscribed:", topic);
|
|
96
|
+
conn.onConnect?.();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.warn("Skipped subscribe - client disconnected or unmounted");
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const disconnect = async (uuid) => {
|
|
105
|
+
if (!clientRef.current || isUnmountedRef.current)
|
|
106
|
+
return;
|
|
107
|
+
const topic = subTopicFromUuid?.(uuid) ?? uuid;
|
|
108
|
+
if (clientRef.current.connected && !clientRef.current.disconnecting) {
|
|
109
|
+
clientRef.current.unsubscribe(topic, async (err) => {
|
|
110
|
+
if (err)
|
|
111
|
+
console.error("Unsubscribe failed:", err);
|
|
112
|
+
else {
|
|
113
|
+
console.log("MQTT Unsubscribed:", topic);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.warn("Skipped subscribe - client disconnected or unmounted");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
function touchHeartbeat(uuid) {
|
|
122
|
+
if (isUnmountedRef.current)
|
|
123
|
+
return; // Stop if unmounted
|
|
124
|
+
clearTimeout(watchdogTimers.current[uuid]?.ping);
|
|
125
|
+
clearTimeout(watchdogTimers.current[uuid]?.warn);
|
|
126
|
+
clearTimeout(watchdogTimers.current[uuid]?.fail);
|
|
127
|
+
const currentConn = base.getConnection(uuid);
|
|
128
|
+
base.updateConnection(uuid, (c) => ({
|
|
129
|
+
...c,
|
|
130
|
+
isConnected: true,
|
|
131
|
+
isConnecting: false
|
|
132
|
+
}));
|
|
133
|
+
const ping = setTimeout(() => {
|
|
134
|
+
if (isUnmountedRef.current)
|
|
135
|
+
return;
|
|
136
|
+
currentConn?.pingFunction?.();
|
|
137
|
+
}, 25000);
|
|
138
|
+
const warn = setTimeout(() => {
|
|
139
|
+
if (isUnmountedRef.current)
|
|
140
|
+
return;
|
|
141
|
+
base.updateConnection(uuid, (c) => ({ ...c, isConnected: false, isConnecting: true }));
|
|
142
|
+
}, 30000);
|
|
143
|
+
const fail = setTimeout(() => {
|
|
144
|
+
if (isUnmountedRef.current)
|
|
145
|
+
return;
|
|
146
|
+
base.updateConnection(uuid, (c) => ({ ...c, isConnected: false, isConnecting: false }));
|
|
147
|
+
}, 60000);
|
|
148
|
+
watchdogTimers.current[uuid] = { ping, warn, fail };
|
|
149
|
+
}
|
|
150
|
+
const defaultOnReceive = (uuid, data) => {
|
|
151
|
+
const conn = base.getConnection(uuid);
|
|
152
|
+
if (!conn)
|
|
153
|
+
return;
|
|
154
|
+
conn.touchHeartbeat?.();
|
|
155
|
+
const decoder = new TextDecoder();
|
|
156
|
+
let bytes;
|
|
157
|
+
if (typeof data === "string") {
|
|
158
|
+
bytes = new TextEncoder().encode(data);
|
|
159
|
+
}
|
|
160
|
+
else if (data instanceof ArrayBuffer) {
|
|
161
|
+
bytes = new Uint8Array(data);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
bytes = data;
|
|
165
|
+
}
|
|
166
|
+
const asText = decoder.decode(bytes);
|
|
167
|
+
base.appendLog(uuid, {
|
|
168
|
+
level: 2,
|
|
169
|
+
message: asText,
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
const defaultSend = async (uuid, data) => {
|
|
173
|
+
const conn = base.getConnection(uuid);
|
|
174
|
+
if (!conn)
|
|
175
|
+
return;
|
|
176
|
+
const payload = typeof data === 'string'
|
|
177
|
+
? new TextEncoder().encode(data)
|
|
178
|
+
: data;
|
|
179
|
+
base.appendLog(uuid, {
|
|
180
|
+
level: 5,
|
|
181
|
+
message: payload,
|
|
182
|
+
});
|
|
183
|
+
clientRef.current?.publish(pubTopicFromUuid?.(uuid) ?? uuid, payload); // TS is wrong here!
|
|
184
|
+
};
|
|
185
|
+
const addConnection = async ({ uuid, propCreator }) => {
|
|
186
|
+
if (!clientRef.current || isUnmountedRef.current)
|
|
187
|
+
return;
|
|
188
|
+
if (!uuid) {
|
|
189
|
+
Error("In MQTT you MUST define a UUID otherwise we don't know what device we're connecting to!");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (base.connectionsRef.current.some(c => c.uuid === uuid) || addingConnections.has(uuid)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
await base.addConnection({
|
|
196
|
+
uuid,
|
|
197
|
+
propCreator: (id) => {
|
|
198
|
+
const props = propCreator?.(id);
|
|
199
|
+
return {
|
|
200
|
+
send: props?.send || ((d) => defaultSend(id, d)),
|
|
201
|
+
onReceive: props?.onReceive || ((d) => defaultOnReceive(id, d)),
|
|
202
|
+
touchHeartbeat: props?.touchHeartbeat || (() => touchHeartbeat(id)),
|
|
203
|
+
...props
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// Delete this adding connections item
|
|
208
|
+
addingConnections.delete(uuid);
|
|
209
|
+
// Connect immediately
|
|
210
|
+
connect(uuid);
|
|
211
|
+
return uuid;
|
|
212
|
+
};
|
|
213
|
+
const removeConnection = async (uuid) => {
|
|
214
|
+
await disconnect(uuid);
|
|
215
|
+
base.removeConnection(uuid);
|
|
216
|
+
};
|
|
217
|
+
const reconnectAll = async () => {
|
|
218
|
+
for (const c of base.connectionsRef.current) {
|
|
219
|
+
await disconnect(c.uuid);
|
|
220
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
221
|
+
}
|
|
222
|
+
for (const c of base.connectionsRef.current) {
|
|
223
|
+
await connect(c.uuid);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
if (autoConnect || props.connectOn)
|
|
228
|
+
connectToMQTTServer();
|
|
229
|
+
}, [serverUrl, props.connectOn]);
|
|
230
|
+
return {
|
|
231
|
+
...base,
|
|
232
|
+
addConnection,
|
|
233
|
+
removeConnection,
|
|
234
|
+
connect,
|
|
235
|
+
disconnect,
|
|
236
|
+
reconnectAll,
|
|
237
|
+
connectToMQTTServer
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -1,31 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Transport } from "esptool-js";
|
|
2
2
|
import { DeviceConnectionState, AddConnectionProps } from "../base/device-whisperer.js";
|
|
3
3
|
export type SerialConnectionState = DeviceConnectionState & {
|
|
4
4
|
port?: SerialPort;
|
|
5
5
|
baudrate?: number;
|
|
6
6
|
transport?: Transport;
|
|
7
|
-
|
|
8
|
-
flashProgress: number;
|
|
9
|
-
flashError: string;
|
|
10
|
-
};
|
|
11
|
-
export type FlashFirmwareProps = {
|
|
12
|
-
uuid: string;
|
|
13
|
-
firmwareBlob?: Blob;
|
|
14
|
-
fileArray?: FlashOptions["fileArray"];
|
|
7
|
+
slipReadWrite?: boolean;
|
|
15
8
|
};
|
|
16
9
|
export declare function SerialMultiDeviceWhisperer<AppOrMessageLayer extends SerialConnectionState>({ ...props }?: {}): {
|
|
17
10
|
addConnection: ({ uuid, propCreator }: AddConnectionProps<AppOrMessageLayer>) => Promise<string>;
|
|
18
11
|
removeConnection: (uuid: string) => Promise<void>;
|
|
19
12
|
connect: (uuid: string, baudrate?: number, restart_on_connect?: boolean) => Promise<void>;
|
|
20
13
|
disconnect: (uuid: string, timeout?: number) => Promise<void>;
|
|
21
|
-
handleFlashFirmware: (uuid: string, assetsToFlash: {
|
|
22
|
-
blob: Blob;
|
|
23
|
-
address: number;
|
|
24
|
-
}[]) => Promise<void>;
|
|
25
14
|
reconnectAll: (...connectionProps: any) => Promise<void>;
|
|
26
15
|
connections: AppOrMessageLayer[];
|
|
27
16
|
connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
|
|
28
17
|
updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
|
|
29
18
|
updateConnectionName: (uuid: string, name: string) => void;
|
|
19
|
+
getConnection: (uuid: string) => AppOrMessageLayer | undefined;
|
|
30
20
|
appendLog: (uuid: string, log: import("../base/device-whisperer.js").LogLine) => void;
|
|
21
|
+
isReady: boolean;
|
|
22
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
31
23
|
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Transport } from "esptool-js";
|
|
2
2
|
import { MultiDeviceWhisperer } from "../base/device-whisperer.js";
|
|
3
|
+
import { useEffect } from "react";
|
|
3
4
|
export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
4
5
|
const base = MultiDeviceWhisperer(props);
|
|
5
6
|
const defaultOnReceive = (uuid, data) => {
|
|
6
|
-
const conn = base.
|
|
7
|
+
const conn = base.getConnection(uuid);
|
|
7
8
|
if (!conn)
|
|
8
9
|
return;
|
|
9
10
|
const decoder = new TextDecoder();
|
|
@@ -35,42 +36,106 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
35
36
|
}
|
|
36
37
|
};
|
|
37
38
|
const defaultSend = async (uuid, data) => {
|
|
38
|
-
const conn = base.
|
|
39
|
+
const conn = base.getConnection(uuid);
|
|
39
40
|
if (!conn)
|
|
40
41
|
return;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Convert to bytes
|
|
43
|
+
const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
44
|
+
// Log for debugging
|
|
44
45
|
base.appendLog(uuid, {
|
|
45
46
|
level: 3,
|
|
46
|
-
message:
|
|
47
|
+
message: typeof data === "string" ? data : btoa(String.fromCharCode(...bytes)),
|
|
47
48
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
await conn.transport?.write(bytes);
|
|
49
|
+
if (!conn.transport)
|
|
50
|
+
return;
|
|
51
|
+
await conn.transport.write(bytes);
|
|
52
|
+
return;
|
|
53
53
|
};
|
|
54
54
|
const readLoop = async (uuid, transport) => {
|
|
55
|
+
const conn = base.getConnection(uuid);
|
|
56
|
+
if (!conn)
|
|
57
|
+
return;
|
|
58
|
+
let readBuffer = ""; // accumulate ASCII/lines
|
|
59
|
+
let slipBuffer = []; // accumulate SLIP frames
|
|
60
|
+
let inSlipFrame = false; // are we inside a SLIP frame?
|
|
61
|
+
let escapeNext = false;
|
|
55
62
|
try {
|
|
63
|
+
const reader = transport.rawRead();
|
|
56
64
|
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
65
|
const { value, done } = await reader.next();
|
|
65
66
|
if (done || !value)
|
|
66
67
|
break;
|
|
67
|
-
|
|
68
|
+
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
69
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
70
|
+
const b = bytes[i];
|
|
71
|
+
if (inSlipFrame) {
|
|
72
|
+
// SLIP decoding
|
|
73
|
+
if (b === 0xC0) {
|
|
74
|
+
if (slipBuffer.length > 0) {
|
|
75
|
+
// complete SLIP frame received
|
|
76
|
+
const payload = new Uint8Array(slipBuffer);
|
|
77
|
+
try {
|
|
78
|
+
conn.onReceive?.(payload);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error("Failed to decode SLIP frame", e);
|
|
82
|
+
base.appendLog(uuid, {
|
|
83
|
+
level: 1,
|
|
84
|
+
message: "Failed to decode message",
|
|
85
|
+
timestamp: new Date(),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
slipBuffer = [];
|
|
89
|
+
}
|
|
90
|
+
inSlipFrame = false;
|
|
91
|
+
escapeNext = false;
|
|
92
|
+
}
|
|
93
|
+
else if (escapeNext) {
|
|
94
|
+
if (b === 0xDC)
|
|
95
|
+
slipBuffer.push(0xC0);
|
|
96
|
+
else if (b === 0xDD)
|
|
97
|
+
slipBuffer.push(0xDB);
|
|
98
|
+
else {
|
|
99
|
+
// protocol violation: discard frame
|
|
100
|
+
slipBuffer = [];
|
|
101
|
+
inSlipFrame = false;
|
|
102
|
+
}
|
|
103
|
+
escapeNext = false;
|
|
104
|
+
}
|
|
105
|
+
else if (b === 0xDB) {
|
|
106
|
+
escapeNext = true;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
slipBuffer.push(b);
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (b === 0xC0 && conn.slipReadWrite) {
|
|
114
|
+
// start of a SLIP frame
|
|
115
|
+
inSlipFrame = true;
|
|
116
|
+
slipBuffer = [];
|
|
117
|
+
escapeNext = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// treat as normal ASCII text
|
|
121
|
+
const char = String.fromCharCode(b);
|
|
122
|
+
readBuffer += char;
|
|
123
|
+
// check for newline
|
|
124
|
+
let newlineIndex;
|
|
125
|
+
while ((newlineIndex = readBuffer.indexOf("\n")) >= 0) {
|
|
126
|
+
const line = readBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
|
127
|
+
readBuffer = readBuffer.slice(newlineIndex + 1);
|
|
128
|
+
if (line.trim()) {
|
|
129
|
+
conn.onReceive?.(line);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
68
133
|
}
|
|
69
134
|
}
|
|
70
135
|
catch (e) {
|
|
71
136
|
base.appendLog(uuid, {
|
|
72
137
|
level: 0,
|
|
73
|
-
message: `[!] Read loop error: ${e}
|
|
138
|
+
message: `[!] Read loop error: ${e}`,
|
|
74
139
|
});
|
|
75
140
|
await disconnect(uuid);
|
|
76
141
|
}
|
|
@@ -84,12 +149,12 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
84
149
|
}));
|
|
85
150
|
base.appendLog(uuid, {
|
|
86
151
|
level: 0,
|
|
87
|
-
message: "[!] Serial disconnected"
|
|
152
|
+
message: "[!] Serial disconnected",
|
|
88
153
|
});
|
|
89
154
|
}
|
|
90
155
|
};
|
|
91
156
|
const restartDevice = async (uuid, default_transport) => {
|
|
92
|
-
const conn = base.
|
|
157
|
+
const conn = base.getConnection(uuid);
|
|
93
158
|
const transport = default_transport ?? conn?.transport;
|
|
94
159
|
if (transport) {
|
|
95
160
|
await transport.setRTS(false);
|
|
@@ -102,7 +167,7 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
102
167
|
}
|
|
103
168
|
};
|
|
104
169
|
const connect = async (uuid, baudrate, restart_on_connect = true) => {
|
|
105
|
-
const conn = base.
|
|
170
|
+
const conn = base.getConnection(uuid);
|
|
106
171
|
if (!conn?.port)
|
|
107
172
|
return;
|
|
108
173
|
if (!conn?.transport) {
|
|
@@ -146,7 +211,7 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
146
211
|
}
|
|
147
212
|
};
|
|
148
213
|
const disconnect = async (uuid, timeout = 2000) => {
|
|
149
|
-
const conn = base.
|
|
214
|
+
const conn = base.getConnection(uuid);
|
|
150
215
|
if (conn?.transport) {
|
|
151
216
|
try {
|
|
152
217
|
// Attempt disconnect, but don’t hang if the port is crashed
|
|
@@ -194,82 +259,6 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
194
259
|
;
|
|
195
260
|
base.removeConnection(uuid);
|
|
196
261
|
};
|
|
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
262
|
const reconnectAll = async (...connectionProps) => {
|
|
274
263
|
const connections = [...base.connectionsRef.current]; // snapshot first
|
|
275
264
|
await Promise.all(connections.map(async (c) => {
|
|
@@ -278,13 +267,15 @@ export function SerialMultiDeviceWhisperer({ ...props } = {}) {
|
|
|
278
267
|
return connect(c.uuid, ...connectionProps);
|
|
279
268
|
}));
|
|
280
269
|
};
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
base.setIsReady(true); // Ready on page load by default
|
|
272
|
+
}, []);
|
|
281
273
|
return {
|
|
282
274
|
...base,
|
|
283
275
|
addConnection,
|
|
284
276
|
removeConnection,
|
|
285
277
|
connect,
|
|
286
278
|
disconnect,
|
|
287
|
-
handleFlashFirmware,
|
|
288
279
|
reconnectAll
|
|
289
280
|
};
|
|
290
281
|
}
|
|
@@ -9,7 +9,10 @@ export type DeviceObjectResponse = {
|
|
|
9
9
|
deviceConnectedTime: Date | null;
|
|
10
10
|
deviceLastCommTime: Date | null;
|
|
11
11
|
};
|
|
12
|
-
export declare function WebsocketMultiDeviceWhisperer<AppOrMessageLayer extends WebsocketConnectionState>(server_url
|
|
12
|
+
export declare function WebsocketMultiDeviceWhisperer<AppOrMessageLayer extends WebsocketConnectionState>({ server_url, server_port, ...props }: {
|
|
13
|
+
server_url: string;
|
|
14
|
+
server_port: number;
|
|
15
|
+
}): {
|
|
13
16
|
addConnection: ({ uuid, propCreator }: AddConnectionProps<AppOrMessageLayer>) => Promise<string>;
|
|
14
17
|
removeConnection: (uuid: string) => Promise<void>;
|
|
15
18
|
connect: (uuid: string, attempt?: number) => Promise<void>;
|
|
@@ -20,5 +23,8 @@ export declare function WebsocketMultiDeviceWhisperer<AppOrMessageLayer extends
|
|
|
20
23
|
connectionsRef: import("react").RefObject<AppOrMessageLayer[]>;
|
|
21
24
|
updateConnection: (uuid: string, updater: (c: AppOrMessageLayer) => AppOrMessageLayer) => void;
|
|
22
25
|
updateConnectionName: (uuid: string, name: string) => void;
|
|
26
|
+
getConnection: (uuid: string) => AppOrMessageLayer | undefined;
|
|
23
27
|
appendLog: (uuid: string, log: import("../base/device-whisperer.js").LogLine) => void;
|
|
28
|
+
isReady: boolean;
|
|
29
|
+
setIsReady: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
24
30
|
};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
1
2
|
import { MultiDeviceWhisperer } from "../base/device-whisperer.js";
|
|
2
|
-
export function WebsocketMultiDeviceWhisperer(server_url, server_port,
|
|
3
|
+
export function WebsocketMultiDeviceWhisperer({ server_url, server_port, ...props }) {
|
|
4
|
+
const base = MultiDeviceWhisperer(props);
|
|
3
5
|
const defaultOnReceive = (uuid, data) => {
|
|
4
|
-
const conn = base.
|
|
6
|
+
const conn = base.getConnection(uuid);
|
|
5
7
|
if (!conn)
|
|
6
8
|
return;
|
|
7
9
|
const decoder = new TextDecoder();
|
|
@@ -33,7 +35,7 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
33
35
|
}
|
|
34
36
|
};
|
|
35
37
|
const defaultSend = async (uuid, data) => {
|
|
36
|
-
const conn = base.
|
|
38
|
+
const conn = base.getConnection(uuid);
|
|
37
39
|
if (!conn || !conn.ws)
|
|
38
40
|
return;
|
|
39
41
|
const asString = typeof data === "string"
|
|
@@ -48,7 +50,7 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
48
50
|
const connect = async (uuid, attempt = 0) => {
|
|
49
51
|
const MAX_RETRIES = 5;
|
|
50
52
|
const RETRY_DELAY_MS = 2000; // 2 seconds
|
|
51
|
-
const conn = base.
|
|
53
|
+
const conn = base.getConnection(uuid);
|
|
52
54
|
if (!conn)
|
|
53
55
|
return;
|
|
54
56
|
if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
|
|
@@ -98,7 +100,7 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
98
100
|
ws: undefined
|
|
99
101
|
}));
|
|
100
102
|
base.appendLog(uuid, { level: 0, message: "[!] WS disconnected" });
|
|
101
|
-
const updated_conn = base.
|
|
103
|
+
const updated_conn = base.getConnection(uuid);
|
|
102
104
|
if (!updated_conn) {
|
|
103
105
|
base.appendLog(uuid, { level: 0, message: "[!] Connection lost!" });
|
|
104
106
|
return;
|
|
@@ -124,7 +126,7 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
124
126
|
}
|
|
125
127
|
};
|
|
126
128
|
const disconnect = async (uuid) => {
|
|
127
|
-
const conn = base.
|
|
129
|
+
const conn = base.getConnection(uuid);
|
|
128
130
|
if (!conn?.ws)
|
|
129
131
|
return;
|
|
130
132
|
base.updateConnection(uuid, (c) => ({
|
|
@@ -138,7 +140,6 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
138
140
|
conn.ws.close();
|
|
139
141
|
await conn?.onDisconnect?.();
|
|
140
142
|
};
|
|
141
|
-
const base = MultiDeviceWhisperer(props);
|
|
142
143
|
const addConnection = async ({ uuid, propCreator }) => {
|
|
143
144
|
return await base.addConnection({
|
|
144
145
|
uuid,
|
|
@@ -181,6 +182,9 @@ export function WebsocketMultiDeviceWhisperer(server_url, server_port, { ...prop
|
|
|
181
182
|
await connect(c.uuid);
|
|
182
183
|
}
|
|
183
184
|
};
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
base.setIsReady(true); // Ready on page load by default
|
|
187
|
+
}, []);
|
|
184
188
|
return {
|
|
185
189
|
...base,
|
|
186
190
|
addConnection,
|
package/package.json
CHANGED
|
@@ -1,53 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ota-hub-reactjs",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "ReactJS tools for building web apps to flash MCU devices such as esp32, brought to you by OTA Hub.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"module": "dist/index.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.
|
|
36
|
-
"unique-names-generator": "^4.7.1",
|
|
37
|
-
"uuid": "^11.1.0"
|
|
38
|
-
},
|
|
39
|
-
"peerDependencies": {
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ota-hub-reactjs",
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"description": "ReactJS tools for building web apps to flash MCU devices such as esp32, brought to you by OTA Hub.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.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.1",
|
|
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
|
+
}
|