diff --git a/android/app/src/main/java/betaflight/configurator/MainActivity.java b/android/app/src/main/java/betaflight/configurator/MainActivity.java index 4671548f80..52b5809f7f 100644 --- a/android/app/src/main/java/betaflight/configurator/MainActivity.java +++ b/android/app/src/main/java/betaflight/configurator/MainActivity.java @@ -4,11 +4,13 @@ import betaflight.configurator.protocols.serial.BetaflightSerialPlugin; import com.getcapacitor.BridgeActivity; +import betaflight.configurator.protocols.tcp.BetaflightTcpPlugin; public class MainActivity extends BridgeActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - registerPlugin(BetaflightSerialPlugin.class); - super.onCreate(savedInstanceState); - } + @Override + public void onCreate(Bundle savedInstanceState) { + registerPlugin(BetaflightSerialPlugin.class); + registerPlugin(BetaflightTcpPlugin.class); + super.onCreate(savedInstanceState); + } } diff --git a/android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java b/android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java new file mode 100644 index 0000000000..3b63b4b122 --- /dev/null +++ b/android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java @@ -0,0 +1,327 @@ +package betaflight.configurator.protocols.tcp; + +import android.util.Base64; +import android.util.Log; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Capacitor plugin that provides raw TCP socket functionality with thread safety, + * robust resource management, and comprehensive error handling. + */ +@CapacitorPlugin(name = "BetaflightTcp") +public class BetaflightTcpPlugin extends Plugin { + private static final String TAG = "BetaflightTcp"; + + // Error messages + private static final String ERROR_IP_REQUIRED = "IP address is required"; + private static final String ERROR_INVALID_PORT = "Invalid port number"; + private static final String ERROR_ALREADY_CONNECTED = "Already connected; please disconnect first"; + private static final String ERROR_NOT_CONNECTED = "Not connected to any server"; + private static final String ERROR_DATA_REQUIRED = "Data is required"; + private static final String ERROR_CONNECTION_LOST = "Connection lost"; + private static final String ERROR_CONNECTION_CLOSED = "Connection closed by peer"; + + // Connection settings + private static final int DEFAULT_TIMEOUT_MS = 30_000; + private static final int MIN_PORT = 1; + private static final int MAX_PORT = 65535; + + private enum ConnectionState { + DISCONNECTED, + CONNECTING, + CONNECTED, + DISCONNECTING, + ERROR + } + + // Thread-safe state and locks + private final AtomicReference state = new AtomicReference<>(ConnectionState.DISCONNECTED); + private final ReentrantLock socketLock = new ReentrantLock(); + private final ReentrantLock writerLock = new ReentrantLock(); + + private Socket socket; + private InputStream input; + private OutputStream output; + private Thread readerThread; + private volatile boolean readerRunning = false; + + @PluginMethod + public void connect(final PluginCall call) { + call.setKeepAlive(true); + final String ip = call.getString("ip"); + + Integer portObj = call.getInt("port"); + final int port = (portObj != null) ? portObj : -1; + + if (ip == null || ip.isEmpty()) { + call.reject(ERROR_IP_REQUIRED); + call.setKeepAlive(false); + return; + } + + if (!compareAndSetState(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) { + call.reject(ERROR_ALREADY_CONNECTED); + call.setKeepAlive(false); + return; + } + + + new Thread(() -> { + socketLock.lock(); + try { + socket = new Socket(); + InetSocketAddress address = new InetSocketAddress(ip, port); + socket.connect(address, DEFAULT_TIMEOUT_MS); + socket.setSoTimeout(DEFAULT_TIMEOUT_MS); + + input = socket.getInputStream(); + output = socket.getOutputStream(); + + state.set(ConnectionState.CONNECTED); + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Connected to " + ip + (port != -1 ? (":" + port) : "")); + + startReaderThread(); + } catch (Exception e) { + state.set(ConnectionState.ERROR); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + call.reject("Connection failed: " + e.getMessage()); + Log.e(TAG, "Connection failed", e); + } finally { + socketLock.unlock(); + call.setKeepAlive(false); + } + }).start(); + } + + @PluginMethod + public void send(final PluginCall call) { + String data = call.getString("data"); + if (data == null || data.isEmpty()) { + call.reject(ERROR_DATA_REQUIRED); + return; + } + if (state.get() != ConnectionState.CONNECTED) { + call.reject(ERROR_NOT_CONNECTED); + return; + } + call.setKeepAlive(true); + + new Thread(() -> { + writerLock.lock(); + try { + if (output == null || state.get() != ConnectionState.CONNECTED) { + call.reject(ERROR_CONNECTION_LOST); + return; + } + byte[] payload = Base64.decode(data, Base64.NO_WRAP); + output.write(payload); + output.flush(); + + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Sent " + payload.length + " bytes"); + } catch (Exception e) { + handleCommunicationError(e, "Send failed", call); + } finally { + writerLock.unlock(); + call.setKeepAlive(false); + } + }).start(); + } + + @PluginMethod + public void receive(final PluginCall call) { + // Deprecated by continuous reader (Task 2) + JSObject result = new JSObject(); + result.put("data", ""); + call.reject("Continuous read active. Listen for 'dataReceived' events instead."); + } + + @PluginMethod + public void disconnect(final PluginCall call) { + ConnectionState current = state.get(); + if (current == ConnectionState.DISCONNECTED) { + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + return; + } + if (!compareAndSetState(current, ConnectionState.DISCONNECTING)) { + call.reject("Invalid state for disconnect: " + current); + return; + } + call.setKeepAlive(true); + + new Thread(() -> { + socketLock.lock(); + try { + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + JSObject result = new JSObject(); + result.put("success", true); + call.resolve(result); + Log.d(TAG, "Disconnected successfully"); + } catch (Exception e) { + state.set(ConnectionState.ERROR); + // Ensure cleanup completes even on error + try { + closeResourcesInternal(); + } catch (Exception ce) { + Log.e(TAG, "Cleanup error during disconnect", ce); + } + call.reject("Disconnect failed: " + e.getMessage()); + Log.e(TAG, "Disconnect failed", e); + // Reset to a clean disconnected state after handling error + state.set(ConnectionState.DISCONNECTED); + } finally { + socketLock.unlock(); + call.setKeepAlive(false); + } + }).start(); + } + + @PluginMethod + public void getStatus(final PluginCall call) { + JSObject result = new JSObject(); + result.put("connected", state.get() == ConnectionState.CONNECTED); + result.put("state", state.get().toString()); + call.resolve(result); + } + + @Override + protected void handleOnDestroy() { + socketLock.lock(); + try { + state.set(ConnectionState.DISCONNECTING); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + } catch (Exception e) { + Log.e(TAG, "Error cleaning up resources on destroy", e); + } finally { + socketLock.unlock(); + } + super.handleOnDestroy(); + } + + private void startReaderThread() { + if (readerThread != null && readerThread.isAlive()) return; + readerRunning = true; + readerThread = new Thread(() -> { + Log.d(TAG, "Reader thread started"); + try { + byte[] buf = new byte[4096]; + while (readerRunning && state.get() == ConnectionState.CONNECTED && input != null) { + int read = input.read(buf); + if (read == -1) { + notifyDisconnectFromPeer(); + break; + } + if (read > 0) { + byte[] chunk = Arrays.copyOf(buf, read); + String b64 = Base64.encodeToString(chunk, Base64.NO_WRAP); + JSObject payload = new JSObject(); + payload.put("data", b64); + notifyListeners("dataReceived", payload); + } + } + } catch (Exception e) { + if (readerRunning) { + Log.e(TAG, "Reader thread error", e); + JSObject err = new JSObject(); + err.put("error", e.getMessage()); + notifyListeners("dataReceivedError", err); + handleCommunicationError(e, "Receive failed", null); + } + } finally { + Log.d(TAG, "Reader thread stopped"); + } + }, "SocketReaderThread"); + readerThread.start(); + } + + private void notifyDisconnectFromPeer() { + Log.d(TAG, "Peer closed connection"); + JSObject evt = new JSObject(); + evt.put("reason", "peer_closed"); + notifyListeners("connectionClosed", evt); + socketLock.lock(); + try { + state.set(ConnectionState.ERROR); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + } finally { + socketLock.unlock(); + } + } + + private void stopReaderThread() { + readerRunning = false; + if (readerThread != null) { + try { + readerThread.interrupt(); + readerThread.join(500); + } catch (InterruptedException ignored) {} + readerThread = null; + } + } + + private void closeResourcesInternal() { + stopReaderThread(); + if (input != null) { try { input.close(); } catch (IOException e) { Log.e(TAG, "Error closing input stream", e); } finally { input = null; } } + if (output != null) { try { output.close(); } catch (IOException e) { Log.e(TAG, "Error closing output stream", e); } finally { output = null; } } + if (socket != null) { try { socket.close(); } catch (IOException e) { Log.e(TAG, "Error closing socket", e); } finally { socket = null; } } + } + + private void handleCommunicationError(Exception error, String message, PluginCall call) { + socketLock.lock(); + try { + state.set(ConnectionState.ERROR); + closeResourcesInternal(); + state.set(ConnectionState.DISCONNECTED); + + String fullMsg = message + ": " + (error != null ? error.getMessage() : "unknown error"); + if (call != null) { + call.reject(fullMsg); + } else { + // No PluginCall available (e.g., background reader thread). Log the error. + Log.e(TAG, fullMsg, error); + // Optionally notify listeners (commented to avoid duplicate notifications): + // JSObject err = new JSObject(); + // err.put("error", fullMsg); + // notifyListeners("socketError", err); + } + Log.e(TAG, message, error); + } finally { + socketLock.unlock(); + } + } + + private boolean compareAndSetState(ConnectionState expected, ConnectionState newState) { + return state.compareAndSet(expected, newState); + } + + private String truncateForLog(String data) { + if (data == null) return "null"; + final int maxLen = 100; + if (data.length() <= maxLen) return data; + return data.substring(0, maxLen) + "... (" + data.length() + " chars)"; + } +} diff --git a/src/js/protocols/CapacitorTcp.js b/src/js/protocols/CapacitorTcp.js new file mode 100644 index 0000000000..70c83521d5 --- /dev/null +++ b/src/js/protocols/CapacitorTcp.js @@ -0,0 +1,179 @@ +import { Capacitor } from "@capacitor/core"; + +const BetaflightTcp = Capacitor?.Plugins?.BetaflightTcp; + +function base64ToUint8Array(b64) { + const binary = atob(b64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + // The atob() function returns a binary string where each character represents a single byte (0–255). + // codePointAt() is designed for Unicode code points and can return values greater than 255, which will overflow Uint8Array slots and corrupt received data. + // Use charCodeAt(i) to safely extract byte values. + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function uint8ArrayToBase64(bytes) { + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +class CapacitorTcp extends EventTarget { + constructor() { + super(); + + this.connected = false; + this.connectionInfo = null; + + this.bitrate = 0; + this.bytesSent = 0; + this.bytesReceived = 0; + this.failed = 0; + + this.logHead = "[TCP]"; + + this.address = "http://localhost:5761"; + + this.plugin = BetaflightTcp; + + this.connect = this.connect.bind(this); + + if (!this.plugin) { + console.warn(`${this.logHead} Native BetaflightTcp plugin is not available`); + return; + } + + this.plugin.addListener("dataReceived", (ev) => { + const bytes = base64ToUint8Array(ev.data); + this.handleReceiveBytes({ detail: bytes }); + // Forward raw bytes as detail; Serial/port_usage consume TypedArray.byteLength. + this.dispatchEvent(new CustomEvent("receive", { detail: bytes })); + }); + + this.plugin.addListener("dataReceivedError", (ev) => { + console.warn("TCP read error:", ev.error); + this.handleDisconnect(); + }); + + this.plugin.addListener("connectionClosed", () => { + console.log("TCP connection closed by peer"); + this.handleDisconnect(); + }); + } + + handleReceiveBytes(info) { + this.bytesReceived += info.detail.byteLength; + } + + handleDisconnect() { + this.disconnect(); + } + + createPort(url) { + this.address = url; + return { + path: url, + displayName: `Betaflight TCP`, + vendorId: 0, + productId: 0, + port: 0, + }; + } + + getConnectedPort() { + return { + path: this.address, + displayName: `Betaflight TCP`, + vendorId: 0, + productId: 0, + port: 0, + }; + } + + async getDevices() { + return []; + } + + async connect(path, options) { + try { + const url = new URL(path); + const host = url.hostname; + const port = Number.parseInt(url.port, 10) || 5761; + + console.log(`${this.logHead} Connecting to ${url}`); + + const result = await this.plugin.connect({ ip: host, port }); + if (result?.success) { + this.address = `${host}:${port}`; + this.connected = true; + } else { + throw new Error("Connect failed"); + } + this.dispatchEvent(new CustomEvent("connect", { detail: this.address })); + } catch (e) { + console.error(`${this.logHead}Failed to connect to socket: ${e}`); + this.connected = false; + this.dispatchEvent(new CustomEvent("connect", { detail: false })); + } + } + + async disconnect() { + this.connected = false; + this.bytesReceived = 0; + this.bytesSent = 0; + + try { + const res = await this.plugin.disconnect(); + if (res.success) { + this.dispatchEvent(new CustomEvent("disconnect", { detail: true })); + } + } catch (e) { + console.error(`${this.logHead}Failed to close connection: ${e}`); + this.dispatchEvent(new CustomEvent("disconnect", { detail: false })); + } + } + + async send(data, cb) { + let actualBytesSent = 0; + if (this.connected) { + const bytes = new Uint8Array(data); + try { + const payload = uint8ArrayToBase64(bytes); + const res = await this.plugin.send({ data: payload }); + + if (res.success) { + actualBytesSent = bytes.byteLength; + this.bytesSent += actualBytesSent; + if (cb) { + cb({ + error: null, + bytesSent: actualBytesSent, + }); + } + } else { + throw new Error("Send failed"); + } + } catch (e) { + console.error(`${this.logHead}Failed to send data e: ${e}`); + + if (cb) { + cb({ + error: e, + bytesSent: 0, + }); + } + } + } + + return { + bytesSent: actualBytesSent, + }; + } +} + +export default CapacitorTcp; diff --git a/src/js/serial.js b/src/js/serial.js index 0633aeada5..5a28a1ffdc 100644 --- a/src/js/serial.js +++ b/src/js/serial.js @@ -4,6 +4,7 @@ import Websocket from "./protocols/WebSocket.js"; import VirtualSerial from "./protocols/VirtualSerial.js"; import { isAndroid } from "./utils/checkCompatibility.js"; import CapacitorSerial from "./protocols/CapacitorSerial.js"; +import CapacitorTcp from "./protocols/CapacitorTcp.js"; /** * Base Serial class that manages all protocol implementations @@ -20,7 +21,10 @@ class Serial extends EventTarget { // Initialize protocols with metadata for easier lookup if (isAndroid()) { - this._protocols = [{ name: "serial", instance: new CapacitorSerial() }]; + this._protocols = [ + { name: "serial", instance: new CapacitorSerial() }, + { name: "tcp", instance: new CapacitorTcp() }, + ]; } else { this._protocols = [ { name: "serial", instance: new WebSerial() }, diff --git a/src/js/serial_backend.js b/src/js/serial_backend.js index 37cd4ae02f..e9aa3ac409 100644 --- a/src/js/serial_backend.js +++ b/src/js/serial_backend.js @@ -61,13 +61,13 @@ export function initializeSerialBackend() { EventBus.$on("port-handler:auto-select-serial-device", function () { if ( - !GUI.connected_to && - !GUI.connecting_to && - !["cli", "firmware_flasher"].includes(GUI.active_tab) && - PortHandler.portPicker.autoConnect && - !isCliOnlyMode() && - (connectionTimestamp === null || connectionTimestamp > 0) || - (Date.now() - rebootTimestamp <= REBOOT_CONNECT_MAX_TIME_MS) + (!GUI.connected_to && + !GUI.connecting_to && + !["cli", "firmware_flasher"].includes(GUI.active_tab) && + PortHandler.portPicker.autoConnect && + !isCliOnlyMode() && + (connectionTimestamp === null || connectionTimestamp > 0)) || + Date.now() - rebootTimestamp <= REBOOT_CONNECT_MAX_TIME_MS ) { connectDisconnect(); } @@ -808,12 +808,10 @@ export function reinitializeConnection() { // Send reboot command to the flight controller MSP.send_message(MSPCodes.MSP_SET_REBOOT, false, false); - if (currentPort.startsWith("bluetooth")) { - if (!PortHandler.portPicker.autoConnect) { - return setTimeout(function () { - $("a.connection_button__link").trigger("click"); - }, 1500); - } + if (currentPort.startsWith("bluetooth") || currentPort === "manual") { + return setTimeout(function () { + $("a.connection_button__link").trigger("click"); + }, 1500); } // Show reboot progress modal except for cli and presets tab