Skip to content

Commit 0fa4034

Browse files
committed
Added support for tauriserial II
1 parent 456d03b commit 0fa4034

File tree

1 file changed

+343
-0
lines changed

1 file changed

+343
-0
lines changed

src/js/protocols/TauriSerial.js

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
import { invoke } from "@tauri-apps/api/core";
2+
import { serialDevices, vendorIdNames } from "./devices";
3+
4+
const logHead = "[TAURI SERIAL]";
5+
6+
/**
7+
* Async generator that polls the serial port for incoming data
8+
* Similar to streamAsyncIterable in WebSerial but uses polling instead of streams
9+
*/
10+
async function* pollSerialData(path, keepReadingFlag) {
11+
try {
12+
while (keepReadingFlag()) {
13+
try {
14+
// Non-blocking read with short timeout
15+
const result = await invoke("plugin:serialplugin|read_binary", {
16+
path,
17+
size: 256,
18+
timeout: 10,
19+
});
20+
21+
if (result && result.length > 0) {
22+
yield new Uint8Array(result);
23+
}
24+
25+
// Small delay between polls to avoid overwhelming the system
26+
await new Promise((resolve) => setTimeout(resolve, 5));
27+
} catch (error) {
28+
// Timeout is expected when no data available
29+
if (!error.toString().includes("no data received")) {
30+
console.warn(`${logHead} Poll error:`, error);
31+
}
32+
// Continue polling
33+
await new Promise((resolve) => setTimeout(resolve, 5));
34+
}
35+
}
36+
} finally {
37+
console.log(`${logHead} Polling stopped for ${path}`);
38+
}
39+
}
40+
41+
/**
42+
* TauriSerial protocol implementation using tauri-plugin-serialplugin
43+
*/
44+
class TauriSerial extends EventTarget {
45+
constructor() {
46+
super();
47+
48+
this.connected = false;
49+
this.openRequested = false;
50+
this.openCanceled = false;
51+
this.closeRequested = false;
52+
this.transmitting = false;
53+
this.connectionInfo = null;
54+
55+
this.bitrate = 0;
56+
this.bytesSent = 0;
57+
this.bytesReceived = 0;
58+
this.failed = 0;
59+
60+
this.ports = [];
61+
this.connectionId = null;
62+
this.reading = false;
63+
64+
this.connect = this.connect.bind(this);
65+
this.disconnect = this.disconnect.bind(this);
66+
this.handleReceiveBytes = this.handleReceiveBytes.bind(this);
67+
68+
// Detect if running on macOS with AT32 (needs batch writes)
69+
this.isNeedBatchWrite = false;
70+
71+
this.loadDevices();
72+
}
73+
74+
handleReceiveBytes(info) {
75+
this.bytesReceived += info.detail.byteLength;
76+
}
77+
78+
getConnectedPort() {
79+
return this.connectionId;
80+
}
81+
82+
async loadDevices() {
83+
try {
84+
const portsMap = await invoke("plugin:serialplugin|available_ports");
85+
86+
// Convert the object map to array
87+
const allPorts = Object.entries(portsMap).map(([path, info]) => {
88+
// The plugin returns vid/pid as decimal strings like "1155", "22336"
89+
let vendorId = undefined;
90+
let productId = undefined;
91+
92+
if (info.vid) {
93+
vendorId = typeof info.vid === "number" ? info.vid : parseInt(info.vid, 10);
94+
}
95+
if (info.pid) {
96+
productId = typeof info.pid === "number" ? info.pid : parseInt(info.pid, 10);
97+
}
98+
99+
return {
100+
path,
101+
displayName: this.getDisplayName(path, vendorId, productId),
102+
vendorId,
103+
productId,
104+
serialNumber: info.serial_number,
105+
};
106+
});
107+
108+
// Filter to only known devices
109+
this.ports = allPorts.filter((port) => {
110+
// Only include ports with known vendor IDs (Betaflight-compatible devices)
111+
if (!port.vendorId || !port.productId) {
112+
return false;
113+
}
114+
// Check if this device is in our known devices list
115+
return serialDevices.some((d) => d.vendorId === port.vendorId && d.productId === port.productId);
116+
});
117+
118+
console.log(`${logHead} Found ${this.ports.length} serial ports (filtered from ${allPorts.length})`);
119+
return this.ports;
120+
} catch (error) {
121+
console.error(`${logHead} Error loading devices:`, error);
122+
return [];
123+
}
124+
}
125+
126+
getDisplayName(path, vendorId, productId) {
127+
let displayName = path;
128+
129+
if (vendorId && productId) {
130+
// Use vendor name if available, otherwise show as hex
131+
const vendorName = vendorIdNames[vendorId] || `VID:${vendorId} PID:${productId}`;
132+
displayName = `Betaflight ${vendorName}`;
133+
}
134+
135+
return displayName;
136+
}
137+
138+
async connect(path, options) {
139+
if (this.openRequested) {
140+
console.log(`${logHead} Connection already requested`);
141+
return false;
142+
}
143+
144+
this.openRequested = true;
145+
146+
try {
147+
const openOptions = {
148+
path,
149+
baudRate: options.baudRate || 115200,
150+
};
151+
152+
console.log(`${logHead} Opening port ${path} at ${openOptions.baudRate} baud`);
153+
154+
// Open the port
155+
const openResult = await invoke("plugin:serialplugin|open", openOptions);
156+
console.log(`${logHead} Open result:`, openResult);
157+
158+
// Set a reasonable timeout for read/write operations (100ms)
159+
try {
160+
await invoke("plugin:serialplugin|set_timeout", {
161+
path,
162+
timeout: 100,
163+
});
164+
} catch (e) {
165+
console.debug(`${logHead} Could not set timeout:`, e);
166+
}
167+
168+
// Connection successful
169+
this.connected = true;
170+
this.connectionId = path;
171+
this.bitrate = openOptions.baudRate;
172+
this.openRequested = false;
173+
174+
this.connectionInfo = {
175+
connectionId: path,
176+
bitrate: this.bitrate,
177+
};
178+
179+
this.addEventListener("receive", this.handleReceiveBytes);
180+
181+
// Start reading
182+
this.reading = true;
183+
this.readLoop();
184+
185+
this.dispatchEvent(new CustomEvent("connect", { detail: true }));
186+
console.log(`${logHead} Connected to ${path}`);
187+
return true;
188+
} catch (error) {
189+
console.error(`${logHead} Error connecting:`, error);
190+
this.openRequested = false;
191+
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
192+
return false;
193+
}
194+
}
195+
196+
async readLoop() {
197+
try {
198+
for await (let value of pollSerialData(this.connectionId, () => this.reading)) {
199+
this.dispatchEvent(new CustomEvent("receive", { detail: value }));
200+
}
201+
} catch (error) {
202+
console.error(`${logHead} Error in read loop:`, error);
203+
if (this.connected) {
204+
this.disconnect();
205+
}
206+
}
207+
}
208+
209+
async send(data, callback) {
210+
if (!this.connected) {
211+
console.error(`${logHead} Cannot send: port not connected`);
212+
const res = { bytesSent: 0 };
213+
callback?.(res);
214+
return res;
215+
}
216+
217+
try {
218+
// Convert data to Uint8Array
219+
let dataArray;
220+
if (data instanceof ArrayBuffer) {
221+
dataArray = new Uint8Array(data);
222+
} else if (data instanceof Uint8Array) {
223+
dataArray = data;
224+
} else if (Array.isArray(data)) {
225+
dataArray = new Uint8Array(data);
226+
} else {
227+
console.error(`${logHead} Unsupported data type:`, data?.constructor?.name);
228+
const res = { bytesSent: 0 };
229+
callback?.(res);
230+
return res;
231+
}
232+
233+
this.transmitting = true;
234+
235+
const writeChunk = async (chunk) => {
236+
await invoke("plugin:serialplugin|write_binary", {
237+
path: this.connectionId,
238+
value: Array.from(chunk),
239+
});
240+
};
241+
242+
if (this.isNeedBatchWrite) {
243+
// Batch write for macOS AT32 compatibility
244+
const batchSize = 63;
245+
for (let offset = 0; offset < dataArray.length; offset += batchSize) {
246+
const chunk = dataArray.slice(offset, offset + batchSize);
247+
await writeChunk(chunk);
248+
}
249+
} else {
250+
await writeChunk(dataArray);
251+
}
252+
253+
this.transmitting = false;
254+
this.bytesSent += dataArray.length;
255+
256+
const res = { bytesSent: dataArray.length };
257+
callback?.(res);
258+
return res;
259+
} catch (error) {
260+
console.error(`${logHead} Error sending data:`, error);
261+
this.transmitting = false;
262+
const res = { bytesSent: 0 };
263+
callback?.(res);
264+
return res;
265+
}
266+
}
267+
268+
async disconnect() {
269+
if (!this.connected) {
270+
return true;
271+
}
272+
273+
// Mark as disconnected immediately
274+
this.connected = false;
275+
this.transmitting = false;
276+
this.reading = false;
277+
278+
if (this.closeRequested) {
279+
return true;
280+
}
281+
282+
this.closeRequested = true;
283+
284+
try {
285+
this.removeEventListener("receive", this.handleReceiveBytes);
286+
287+
// Small delay to allow read loop to notice state change
288+
await new Promise((resolve) => setTimeout(resolve, 50));
289+
290+
// Close the port
291+
if (this.connectionId) {
292+
try {
293+
await invoke("plugin:serialplugin|close", { path: this.connectionId });
294+
console.log(`${logHead} Port closed`);
295+
} catch (error) {
296+
console.warn(`${logHead} Error closing port:`, error);
297+
}
298+
}
299+
300+
this.connectionId = null;
301+
this.bitrate = 0;
302+
this.connectionInfo = null;
303+
this.closeRequested = false;
304+
305+
this.dispatchEvent(new CustomEvent("disconnect", { detail: true }));
306+
return true;
307+
} catch (error) {
308+
console.error(`${logHead} Error disconnecting:`, error);
309+
this.closeRequested = false;
310+
this.dispatchEvent(new CustomEvent("disconnect", { detail: false }));
311+
return false;
312+
} finally {
313+
if (this.openCanceled) {
314+
this.openCanceled = false;
315+
}
316+
}
317+
}
318+
319+
async getDevices() {
320+
await this.loadDevices();
321+
return this.ports;
322+
}
323+
324+
removePort(path) {
325+
const removed = this.ports.find((p) => p.path === path);
326+
this.ports = this.ports.filter((p) => p.path !== path);
327+
if (removed) {
328+
this.dispatchEvent(new CustomEvent("removedDevice", { detail: removed }));
329+
}
330+
}
331+
332+
addPort(path) {
333+
// Reload devices to get updated port info
334+
this.loadDevices().then(() => {
335+
const added = this.ports.find((p) => p.path === path);
336+
if (added) {
337+
this.dispatchEvent(new CustomEvent("addedDevice", { detail: added }));
338+
}
339+
});
340+
}
341+
}
342+
343+
export default TauriSerial;

0 commit comments

Comments
 (0)