Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
570d3eb
feat: convert configuration tab to vue and rebase from master
Erics1337 Dec 7, 2025
b2fce43
fix: Vue Migration: Servos & Serial (#4725)
Erics1337 Dec 9, 2025
9a4ad42
fix(ConfigurationTab): Resolve CodeRabbit review comments
Erics1337 Dec 9, 2025
7d1d1c4
fix: features configuration table to fix styling issues and fix loca…
Erics1337 Dec 11, 2025
7c07d39
chore: remove legacy configuration tab HTML and JS files
Erics1337 Dec 11, 2025
14fa3f1
Resolve conflict from rebase
Erics1337 Dec 11, 2025
49235b5
Add custom Capacitor plugin-socket for raw TCP support (#4471)
haslinghuis Dec 10, 2025
80278fa
fix(config-tab): improve table accessibility and fix headers
Erics1337 Dec 11, 2025
08381fd
feat: Introduce 'Other Features' translation and help icon, and refin…
Erics1337 Dec 11, 2025
519a2cb
fix coderabbit comments
Erics1337 Dec 11, 2025
e4fb57f
Update src/components/tabs/ConfigurationTab.vue
Erics1337 Dec 11, 2025
b723dbf
refactor: Make sensor alignment display conditional on API version, a…
Erics1337 Dec 11, 2025
151c269
fix: Conditionally display legacy sensor alignment options based on A…
Erics1337 Dec 11, 2025
421d1c4
fix: Show Sensor Alignment box and fix magnetometer dropdown
Erics1337 Dec 12, 2025
9575f09
Address comments: Add custom magnetic alignment options, update gyro …
Erics1337 Dec 13, 2025
50cc3f9
Address coderabbit comments: Implement custom roll/pitch/yaw alignmen…
Erics1337 Dec 13, 2025
e0e183f
feat: Remove custom gyro alignment angle inputs and associated data h…
Erics1337 Dec 13, 2025
a5a5de3
address coderabbit comments: correct feature list rendering, ensure a…
Erics1337 Dec 14, 2025
15a6d45
address coderabbit comments: Update gyro frequency display key and co…
Erics1337 Dec 14, 2025
f3e727b
address coderabbit comments: add custom roll, pitch, and yaw alignmen…
Erics1337 Dec 14, 2025
e63ca72
address more coderabbit comments: Correct configuration help text ren…
Erics1337 Dec 14, 2025
665ab3f
fix: Rename "Other Features" UI section to "Sensor Configuration", up…
Erics1337 Dec 14, 2025
813e42a
refactor: Separate magnetometer alignment section, update gyro alignm…
Erics1337 Dec 16, 2025
9f6a99e
feat: Implement multi-gyro array support in backend to align with PR …
Erics1337 Dec 18, 2025
562335d
refactor: Update gyro alignment properties from arrays to distinct gy…
Erics1337 Dec 18, 2025
17c6a9e
fix: import TABS from gui module
Erics1337 Dec 18, 2025
a551469
fix: validate remaining data length before reading magnetometer align…
Erics1337 Dec 18, 2025
8b35c89
fix: Remove redundant `key` attributes from `<tbody>` elements and pr…
Erics1337 Dec 18, 2025
f734a61
Resolve conflict: Remove MainActivity.java
Erics1337 Dec 18, 2025
2008d7b
fix: remove duplicate ServosTab import and component registrations
Erics1337 Dec 18, 2025
1e62119
fix: Add reboot connection timeout and grace period constants, and a …
Erics1337 Dec 18, 2025
6af7970
refactor(locales): cleanup gyro keys and remove unused config strings
Erics1337 Dec 19, 2025
6fe675c
fix(msp): ensure custom gyro alignment persists for legacy API
Erics1337 Dec 19, 2025
51dc9e7
fix(ui): improve configuration tab robustness and rendering
Erics1337 Dec 19, 2025
b49dde9
Update src/js/msp/MSPHelper.js
Erics1337 Dec 20, 2025
098df4a
feat: Implement Multi-Gyro support for API 1.47+
Erics1337 Dec 20, 2025
dd0abbb
revert
Erics1337 Dec 21, 2025
52d316e
Fix Configuration Tab IMU display and revert backend changes
Erics1337 Dec 21, 2025
03dac7d
Fix multi-gyro display for API 1.47+
Erics1337 Dec 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<ConnectionState> 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;
Comment on lines +66 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Port validation is incomplete - negative port will cause InetSocketAddress exception.

Line 67 sets port = -1 when portObj is null, but there's no subsequent validation to reject invalid ports before attempting connection on line 86. InetSocketAddress will throw IllegalArgumentException for ports outside 0-65535.

🔎 Proposed fix to validate port before connection
         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 (port < MIN_PORT || port > MAX_PORT) {
+            call.reject(ERROR_INVALID_PORT);
+            call.setKeepAlive(false);
+            return;
+        }
         
         if (!compareAndSetState(ConnectionState.DISCONNECTED, ConnectionState.CONNECTING)) {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java
around lines 66-67 (and connection at ~86), the code defaults port to -1 when
missing but does not validate the value; this can cause InetSocketAddress to
throw IllegalArgumentException for ports outside 0-65535. Validate that portObj
is present and that the resulting int is within 0..65535 before attempting to
create the socket/address; if invalid, return or throw a clear error (e.g., send
an error result or log and abort the connection) so connection code at line ~86
never receives an out-of-range port.


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)";
}
}
Loading