-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Add custom Capacitor plugin-socket for raw TCP support #4471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
haslinghuis
wants to merge
52
commits into
betaflight:master
Choose a base branch
from
haslinghuis:capacitor-plugin-socket
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+529
−19
Open
Changes from all commits
Commits
Show all changes
52 commits
Select commit
Hold shift + click to select a range
f7b72f5
Add custom Capacitor plugin-socket
haslinghuis db59c4c
Rabbit fixes
haslinghuis ff761bd
Refactor SocketPlugin.java
haslinghuis 3459908
Move receive operation to background thread
haslinghuis f9227f6
update isConnected flag in closeResources catch block
haslinghuis fd47ce3
Handle null return from readLine
haslinghuis f12800a
guard against missing or null port parameter
haslinghuis 7831a16
fix inconsistent error handling in receive method
haslinghuis 206beff
Rabbit keeps nitpicking
haslinghuis 394cb2f
Suggested by rabbit
haslinghuis 958d7ad
Add getStatus
haslinghuis 7d3b527
Allow new connect attempt
haslinghuis fec6a0d
Replace file with rabbit suggestion again
haslinghuis 80d5d40
Add back getStatus
haslinghuis 8a52020
Add getStatus to web.ts too
haslinghuis a99140b
Update message
haslinghuis 073704d
Change data stream
haslinghuis 788df02
Continuous reading
haslinghuis 9645aae
Data Format
haslinghuis f33a7c3
Guard against null PluginCall in handleCommunicationError
haslinghuis 65237c5
Reset state to DISCONNECTED after disconnect failure
haslinghuis 3d996d8
Indentation
haslinghuis 0e25407
Integrated android implementation
haslinghuis a3e1f54
Adding events to TCP
haslinghuis f0380b2
Refactor
haslinghuis b57fa46
Remove newline in java implementation
haslinghuis 808b36b
text is undefined in dataReceived handler, causing runtime errors
haslinghuis cec2da8
review fixes
haslinghuis dcbfbdb
Fix bytesSent
haslinghuis 77cf31b
Fix url:port and add conversion
haslinghuis fb2310d
Fix bytesSent
haslinghuis 900d5e8
Fix
haslinghuis 91dc7bd
Move return outside condition
haslinghuis 1aea9bf
Use new Thread instead of getBridge().getExecutor()
haslinghuis 4386c20
Fix undefined at load
haslinghuis 18d2577
Port is optional
haslinghuis d358396
Actually use the plugin
haslinghuis f0234ff
Fix state remains stuck in ERROR after failed connect
haslinghuis b0db78f
Ignore port if not provided by user
haslinghuis 5e2e88e
Fix data format
haslinghuis 884e3f5
Fix disconnect initiated on user request
haslinghuis 3d6a0f7
Fix disconnect initiated on user request II
haslinghuis b61bc56
Fix disconnect initiated on user request III
haslinghuis 10f552a
Rename
haslinghuis dcab247
Add missing file
haslinghuis 8eec1f6
Reduce formatting I
haslinghuis ace69aa
Address review comments
haslinghuis 4bd814d
Fix save and reboot closing UX
haslinghuis 55d049c
Default to port 80
haslinghuis 64a2ba8
Thanks to CapnBry for providing default port
haslinghuis 96b8cf8
Some nitpicks
haslinghuis dcdaca1
Some nitpicks II
haslinghuis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
327 changes: 327 additions & 0 deletions
327
android/app/src/main/java/betaflight/configurator/protocols/tcp/BetaflightTcpPlugin.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
|
|
||
| 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)"; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.