diff --git a/src/components/port-picker/PortOverrideOption.vue b/src/components/port-picker/PortOverrideOption.vue
index e3f0c95fe86..7480e4ac404 100644
--- a/src/components/port-picker/PortOverrideOption.vue
+++ b/src/components/port-picker/PortOverrideOption.vue
@@ -1,8 +1,7 @@
diff --git a/src/js/data_storage.js b/src/js/data_storage.js
index efb37b3498e..38b391d387f 100644
--- a/src/js/data_storage.js
+++ b/src/js/data_storage.js
@@ -11,6 +11,7 @@ const CONFIGURATOR = {
connectionValid: false,
connectionValidCliOnly: false,
+ manualMode: false,
virtualMode: false,
virtualApiVersion: '0.0.1',
cliActive: false,
diff --git a/src/js/protocols/websocket.js b/src/js/protocols/websocket.js
new file mode 100644
index 00000000000..418eda0a153
--- /dev/null
+++ b/src/js/protocols/websocket.js
@@ -0,0 +1,145 @@
+class WebsocketSerial extends EventTarget {
+ constructor() {
+ super();
+
+ this.connected = false;
+ this.connectionInfo = null;
+
+ this.bitrate = 0;
+ this.bytesSent = 0;
+ this.bytesReceived = 0;
+ this.failed = 0;
+
+ this.logHead = "[WEBSOCKET] ";
+
+ this.address = "ws://localhost:5761";
+
+ this.ws = null;
+
+ this.connect = this.connect.bind(this);
+ }
+
+ handleReceiveBytes(info) {
+ this.bytesReceived += info.detail.byteLength;
+ }
+
+ handleDisconnect() {
+ this.disconnect();
+ }
+
+ createPort(url) {
+ this.address = url;
+ return {
+ path: url,
+ displayName: `Betaflight SITL`,
+ vendorId: 0,
+ productId: 0,
+ port: 0,
+ };
+ }
+
+ getConnectedPort() {
+ return {
+ path: this.address,
+ displayName: `Betaflight SITL`,
+ vendorId: 0,
+ productId: 0,
+ port: 0,
+ };
+ }
+
+ async getDevices() {
+ return [];
+ }
+
+ async blob2uint(blob) {
+ const buffer = await new Response(blob).arrayBuffer();
+ return new Uint8Array(buffer);
+ }
+
+ waitForConnection(socket) {
+ return new Promise((resolve) => {
+ const interval = setInterval(() => {
+ if (socket.connected) {
+ clearInterval(interval); // Stop checking
+ resolve(); // Resolve the promise
+ }
+ }, 100); // Check every 100ms, adjust as needed
+ });
+ }
+
+ async connect(path, options) {
+ this.address = path;
+ console.log(`${this.logHead} Connecting to ${this.address}`);
+
+ this.ws = new WebSocket(this.address, "wsSerial");
+ let socket = this;
+
+ this.ws.onopen = function(e) {
+ console.log(`${socket.logHead} Connected: `, e);
+ socket.connected = true;
+ socket.dispatchEvent(
+ new CustomEvent("connect", { detail: {
+ socketId: socket.address,
+ }}),
+ );
+ };
+
+ await this.waitForConnection(socket);
+
+ this.ws.onclose = function(e) {
+ console.log(`${socket.logHead} Connection closed: `, e);
+
+ socket.disconnect(() => {
+ socket.dispatchEvent(new CustomEvent("disconnect", this.disconnect.bind(this)));
+ });
+ };
+
+ this.ws.onerror = function(e) {
+ console.error(`${socket.logHead} Connection error: `, e);
+
+ socket.disconnect(() => {
+ socket.dispatchEvent(new CustomEvent("disconnect", this.disconnect.bind(this)));
+ });
+ };
+
+ this.ws.onmessage = async function(msg) {
+ let uint8Chunk = await socket.blob2uint(msg.data);
+ socket.dispatchEvent(
+ new CustomEvent("receive", { detail: uint8Chunk }),
+ );
+ };
+ }
+
+ async disconnect() {
+ this.connected = false;
+ this.bytesReceived = 0;
+ this.bytesSent = 0;
+
+ if (this.ws) {
+ try {
+ this.ws.close();
+ } catch (e) {
+ console.error(`${this.logHead}Failed to close socket: ${e}`);
+ }
+ }
+ }
+
+ async send(data, cb) {
+ if (this.ws) {
+ try {
+ this.ws.send(data);
+ this.bytesSent += data.byteLength;
+ }
+ catch(e) {
+ console.error(`${this.logHead}Failed to send data e: ${e}`);
+ }
+ }
+
+ return {
+ bytesSent: data.byteLength,
+ };
+ }
+}
+
+export default new WebsocketSerial();
diff --git a/src/js/serial_backend.js b/src/js/serial_backend.js
index bc95ca4ffc1..bc96fdd6868 100644
--- a/src/js/serial_backend.js
+++ b/src/js/serial_backend.js
@@ -107,7 +107,6 @@ function connectDisconnect() {
GUI.configuration_loaded = false;
const baudRate = PortHandler.portPicker.selectedBauds;
- const selectedPort = portName;
if (!isConnected) {
// prevent connection when we do not have permission
@@ -124,6 +123,7 @@ function connectDisconnect() {
CONFIGURATOR.virtualMode = selectedPort === 'virtual';
CONFIGURATOR.bluetoothMode = selectedPort.startsWith('bluetooth');
+ CONFIGURATOR.manualMode = selectedPort === 'manual';
if (CONFIGURATOR.virtualMode) {
CONFIGURATOR.virtualApiVersion = PortHandler.portPicker.virtualMspVersion;
@@ -131,6 +131,16 @@ function connectDisconnect() {
// Hack to get virtual working on the web
serial = serialShim();
serial.connect(onOpenVirtual);
+ } else if (selectedPort === 'manual') {
+ serial = serialShim();
+ // Explicitly disconnect the event listeners before attaching the new ones.
+ serial.removeEventListener('connect', connectHandler);
+ serial.addEventListener('connect', connectHandler);
+
+ serial.removeEventListener('disconnect', disconnectHandler);
+ serial.addEventListener('disconnect', disconnectHandler);
+
+ serial.connect(portName, { baudRate });
} else {
CONFIGURATOR.virtualMode = false;
serial = serialShim();
diff --git a/src/js/serial_shim.js b/src/js/serial_shim.js
index dee494a6392..bcec5bf1041 100644
--- a/src/js/serial_shim.js
+++ b/src/js/serial_shim.js
@@ -1,6 +1,18 @@
import CONFIGURATOR from "./data_storage";
import serialWeb from "./webSerial.js";
import BT from "./protocols/bluetooth.js";
+import websocketSerial from "./protocols/websocket.js";
import virtualSerial from "./virtualSerial.js";
-export let serialShim = () => CONFIGURATOR.virtualMode ? virtualSerial: CONFIGURATOR.bluetoothMode ? BT : serialWeb;
+export const serialShim = () => {
+ if (CONFIGURATOR.virtualMode) {
+ return virtualSerial;
+ }
+ if (CONFIGURATOR.manualMode) {
+ return websocketSerial;
+ }
+ if (CONFIGURATOR.bluetoothMode) {
+ return BT;
+ }
+ return serialWeb;
+}
\ No newline at end of file