add slcan serial support

This commit is contained in:
2026-05-23 08:56:19 +08:00
parent b50cb503a8
commit 1cd3badb2d
3 changed files with 253 additions and 24 deletions

View File

@@ -64,6 +64,11 @@ const CONNECTION_TYPES = {
WEBSOCKET: 'websocket'
};
const SERIAL_PROTOCOLS = {
MAVLINK: 'mavlink',
SLCAN: 'slcan'
};
// Add this constant inside the ConnectionSettingsModal.js file, outside the component
const INTERFACE_BUS_LIST = [0, 1]; //BUS 1, BUS 2
@@ -85,6 +90,7 @@ const ConnectionSettingsModal = ({
const [ports, setPorts] = useState([]);
const [selectedPort, setSelectedPort] = useState(null);
const [baudRate, setBaudRate] = useState(DEFAULT_BAUD_RATE);
const [serialProtocol, setSerialProtocol] = useState(SERIAL_PROTOCOLS.MAVLINK);
const [wsHost, setWsHost] = useState(DEFAULT_WS_HOST);
const [wsPort, setWsPort] = useState(DEFAULT_WS_PORT);
@@ -93,6 +99,7 @@ const ConnectionSettingsModal = ({
// Track active connection
const [activeConnection, setActiveConnection] = useState(null); // null, 'serial', or 'websocket'
const [activeSerialProtocol, setActiveSerialProtocol] = useState(null);
// Add these state variables after your other state declarations
const [hostError, setHostError] = useState('');
@@ -240,6 +247,7 @@ const ConnectionSettingsModal = ({
// Update the UI regardless of connection state
setActiveConnection(null);
setActiveSerialProtocol(null);
onConnectionStatusChange(false);
showMessage('Serial connection closed', 'info');
@@ -264,25 +272,28 @@ const ConnectionSettingsModal = ({
}
}
window.mavlinkSession.initWebSerialConnection(port, baudRate);
window.mavlinkSession.initWebSerialConnection(port, baudRate, { protocol: serialProtocol });
window.mavlinkSession.addWebSerialOpenHandler(() => {
// Set Node ID and Bus for the local node
window.localNode.setNodeId(parseInt(nodeId, 10));
window.localNode.setBus(selectedBus);
if (serialProtocol === SERIAL_PROTOCOLS.MAVLINK) {
const intervalId = setInterval(() => {
if (window.mavlinkSession) {
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
}
}, 1000);
setForwardingInterval(intervalId);
} else {
setForwardingInterval(null);
}
// Start the mavlinkCanForward interval
const intervalId = setInterval(() => {
if (window.mavlinkSession) {
window.mavlinkSession.enableMavlinkCanForward(window.localNode.bus);
}
}, 1000);
setForwardingInterval(intervalId);
setActiveConnection('serial');
setActiveSerialProtocol(serialProtocol);
setConnectionInProgress(false);
onConnectionStatusChange(true);
showMessage('Serial connection established', 'success');
showMessage(`Serial ${serialProtocol === SERIAL_PROTOCOLS.SLCAN ? 'SLCAN' : 'MAVLink'} connection established`, 'success');
})
window.mavlinkSession.addWebSerialErrorHandler((error) => {
@@ -404,6 +415,7 @@ const ConnectionSettingsModal = ({
// Force close the connection
window.mavlinkSession.close();
setActiveConnection(null);
setActiveSerialProtocol(null);
onConnectionStatusChange(false);
showMessage('WebSocket connection closed', 'info');
if (forwardingInterval) {
@@ -447,6 +459,7 @@ const ConnectionSettingsModal = ({
}, 1000);
setForwardingInterval(intervalId);
setActiveConnection('websocket');
setActiveSerialProtocol(null);
onConnectionStatusChange(true);
setConnectionInProgress(false);
showMessage('WebSocket connection established', 'success');
@@ -512,7 +525,7 @@ const ConnectionSettingsModal = ({
<Box display="flex" alignItems="center" gap={1}>
{activeConnection && (
<Chip
label={`Connected via ${activeConnection}`}
label={activeConnection === 'serial' && activeSerialProtocol ? `Connected via serial (${activeSerialProtocol === SERIAL_PROTOCOLS.SLCAN ? 'SLCAN' : 'MAVLink'})` : `Connected via ${activeConnection}`}
color="success"
size="small"
variant="outlined"
@@ -587,6 +600,18 @@ const ConnectionSettingsModal = ({
</Box>
</Box>
<FormControl fullWidth size="small" disabled={activeConnection !== null}>
<InputLabel>Serial Protocol</InputLabel>
<Select
value={serialProtocol}
onChange={(e) => setSerialProtocol(e.target.value)}
label="Serial Protocol"
>
<MenuItem value={SERIAL_PROTOCOLS.MAVLINK}>MAVLink tunnel</MenuItem>
<MenuItem value={SERIAL_PROTOCOLS.SLCAN}>SLCAN / LAWICEL</MenuItem>
</Select>
</FormControl>
{/* Port action buttons */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button

View File

@@ -1,6 +1,7 @@
import { EventEmitter } from 'events';
import WebSocketClient from './ws_client';
import WebSerial from './web_serial';
import SlcanCodec from './slcan';
import dronecan from './dronecan';
import './mavlink';
@@ -12,6 +13,8 @@ class MavlinkSession extends EventEmitter {
this.mavlinkProcessor = new MAVLink20Processor(null, this.targetSystem, this.targetComponent);
this.wsClient = null;
this.serial = null;
this.serialProtocol = 'mavlink';
this.slcanCodec = null;
this.parseBuffer = this.parseBuffer.bind(this);
}
@@ -28,6 +31,9 @@ class MavlinkSession extends EventEmitter {
}
initWebSocketConnection(ip, port, mavlinkSigning='') {
this.serialProtocol = 'mavlink';
this.slcanCodec = null;
if (mavlinkSigning) {
const enc = new TextEncoder();
const data = enc.encode(mavlinkSigning);
@@ -49,13 +55,21 @@ class MavlinkSession extends EventEmitter {
this.wsClient.connect();
}
initWebSerialConnection(port, baudRate) {
initWebSerialConnection(port, baudRate, options = {}) {
this.serial = new WebSerial(port, baudRate);
this.mavlinkProcessor.file = this.serial;
this.serialProtocol = options.protocol || 'mavlink';
this.slcanCodec = this.serialProtocol === 'slcan' ? new SlcanCodec() : null;
if (this.serialProtocol === 'mavlink') {
this.mavlinkProcessor.file = this.serial;
}
this.serial.addMessageHandler((buffer) => {
// console.log('Received buffer:', buffer);
this.parseBuffer(buffer);
if (this.serialProtocol === 'slcan') {
this.parseSlcanBuffer(buffer);
} else {
this.parseBuffer(buffer);
}
});
}
@@ -72,7 +86,11 @@ class MavlinkSession extends EventEmitter {
}
webSerialConnect() {
this.serial.connect();
this.serial.connect().then(() => {
if (this.serialProtocol === 'slcan') {
this.openSlcanChannel();
}
});
}
close() {
@@ -84,6 +102,17 @@ class MavlinkSession extends EventEmitter {
}
}
openSlcanChannel() {
if (!this.serial || this.serialProtocol !== 'slcan') return;
console.log('SLCAN: opening CAN channel');
setTimeout(() => {
if (this.serial && this.serial.connected) {
this.serial.write('O\r');
}
}, 0);
}
parseBuffer(buffer) {
// console.log('Parsing buffer:', buffer);
const messages = this.mavlinkProcessor.parseBuffer(buffer);
@@ -94,6 +123,21 @@ class MavlinkSession extends EventEmitter {
}
}
parseSlcanBuffer(buffer) {
this.slcanCodec.feed(
buffer,
({ id, data, len }) => {
console.log('SLCAN RX frame:', { id: id.toString(16), len, data });
if (typeof localNode !== 'undefined' && localNode) {
localNode.emit('can-frame', (id | dronecan.TransferManager.FlagEFF) >>> 0, data, len);
}
if (this.listenerCount('mav-rx') > 0) {
this.emit('mav-rx', { protocol: 'slcan', id, data, len });
}
}
);
}
handleMavlinkMsg(message) {
switch (message._id) {
case mavlink20.MAVLINK_MSG_ID_HEARTBEAT:
@@ -119,21 +163,49 @@ class MavlinkSession extends EventEmitter {
default:
}
if (this.getMaxListeners('mav-rx') > 0) {
if (this.listenerCount('mav-rx') > 0) {
this.emit('mav-rx', message);
}
}
sendMavlinkMsg(msg) {
if (this.serialProtocol === 'slcan') return;
if ((this.wsClient && this.wsClient.connected) || (this.serial && this.serial.connected)) {
this.mavlinkProcessor.send(msg);
if (this.getMaxListeners('mav-tx') > 0) {
if (this.listenerCount('mav-tx') > 0) {
this.emit('mav-tx', msg);
}
}
}
sendCanFrame(bus, messageId, data, len) {
if (this.serialProtocol === 'slcan') {
if (this.serial && this.serial.connected && this.slcanCodec) {
const line = this.slcanCodec.encodeExtendedFrame(messageId, data, len);
console.log('SLCAN TX frame:', line.trim());
this.serial.write(line);
if (this.listenerCount('mav-tx') > 0) {
this.emit('mav-tx', { protocol: 'slcan', id: messageId, data, len });
}
}
return;
}
const msg = new mavlink20.messages.can_frame(
this.targetSystem,
this.targetComponent,
bus,
len,
messageId,
data.toString('binary')
);
this.sendMavlinkMsg(msg);
}
enableMavlinkCanForward(bus) {
if (this.serialProtocol === 'slcan') return;
// console.log('Enabling CAN forward on bus:', bus);
const msg = new mavlink20.messages.command_long(
this.targetSystem, // target_system

132
src/slcan.js Normal file
View File

@@ -0,0 +1,132 @@
class SlcanCodec {
constructor() {
this.buffer = '';
this.decoder = new TextDecoder('ascii');
}
feed(chunk, onFrame, onError) {
const text = this.decoder.decode(chunk, { stream: true });
if (text.includes('\x07')) {
console.warn('SLCAN adapter returned NACK');
}
this.buffer += text.replace(/\x07/g, '');
this.parseBuffer(onFrame);
}
parseBuffer(onFrame) {
while (this.buffer.length > 0) {
this.buffer = this.buffer.replace(/^[\r\n]+/, '');
if (!this.buffer) return;
const frameStart = this.buffer.search(/[TtRr]/);
if (frameStart === -1) {
this.buffer = '';
return;
}
if (frameStart > 0) {
this.buffer = this.buffer.slice(frameStart);
}
const type = this.buffer[0];
if (type === 'T') {
if (this.buffer.length < 10) return;
const dlcText = this.buffer[9];
if (!/^[0-8]$/.test(dlcText)) {
console.warn(`Malformed SLCAN frame header: ${this.buffer.slice(0, 10)}`);
this.buffer = this.buffer.slice(1);
continue;
}
const dlc = parseInt(dlcText, 16);
const frameLength = 10 + dlc * 2;
if (this.buffer.length < frameLength) return;
const line = this.buffer.slice(0, frameLength);
this.buffer = this.buffer.slice(frameLength).replace(/^[\r\n]+/, '');
this.parseLine(line, onFrame);
continue;
}
if (type === 't') {
if (this.buffer.length < 5) return;
const dlcText = this.buffer[4];
if (!/^[0-8]$/.test(dlcText)) {
this.buffer = this.buffer.slice(1);
continue;
}
const frameLength = 5 + parseInt(dlcText, 16) * 2;
if (this.buffer.length < frameLength) return;
this.buffer = this.buffer.slice(frameLength).replace(/^[\r\n]+/, '');
continue;
}
const lineEndIndex = this.findLineEnd();
if (lineEndIndex === -1) return;
this.buffer = this.buffer.slice(lineEndIndex + 1);
}
}
findLineEnd() {
const crIndex = this.buffer.indexOf('\r');
const lfIndex = this.buffer.indexOf('\n');
if (crIndex === -1) return lfIndex;
if (lfIndex === -1) return crIndex;
return Math.min(crIndex, lfIndex);
}
parseLine(line, onFrame) {
if (!line) return;
const type = line[0];
if (type === 't' || type === 'r' || type === 'R') return;
if (type !== 'T') return;
if (line.length < 10) {
console.warn(`Malformed SLCAN frame: ${line}`);
return;
}
const idText = line.slice(1, 9);
const dlcText = line[9];
if (!/^[0-9a-fA-F]{8}$/.test(idText) || !/^[0-8]$/.test(dlcText)) {
console.warn(`Malformed SLCAN frame: ${line}`);
return;
}
const dlc = parseInt(dlcText, 16);
const dataText = line.slice(10, 10 + dlc * 2);
if (dataText.length !== dlc * 2 || !/^[0-9a-fA-F]*$/.test(dataText)) {
console.warn(`Malformed SLCAN payload: ${line}`);
return;
}
const data = new Uint8Array(dlc);
for (let i = 0; i < dlc; i++) {
data[i] = parseInt(dataText.slice(i * 2, i * 2 + 2), 16);
}
onFrame?.({
id: parseInt(idText, 16) & 0x1FFFFFFF,
data,
len: dlc,
});
}
encodeExtendedFrame(messageId, data, len) {
const canId = messageId & 0x1FFFFFFF;
const frameLength = Math.min(len, data.length ?? len);
if (frameLength > 8) {
throw new Error('SLCAN classic CAN frames support a maximum DLC of 8');
}
let dataHex = '';
for (let i = 0; i < frameLength; i++) {
dataHex += data[i].toString(16).toUpperCase().padStart(2, '0');
}
return `T${canId.toString(16).toUpperCase().padStart(8, '0')}${frameLength.toString(16).toUpperCase()}${dataHex}\r`;
}
}
export default SlcanCodec;