react-native-web-serial-api 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -117
- package/TESTING.md +417 -176
- package/android/build.gradle +14 -0
- package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
- package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
- package/bin/expose-serial.js +205 -0
- package/lib/commonjs/UsbSerial.js +1 -1
- package/lib/commonjs/WebSerial.js +110 -26
- package/lib/commonjs/WebSerial.js.map +1 -1
- package/lib/commonjs/index.js +2 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/lib/event-target.js +3 -1
- package/lib/commonjs/lib/event-target.js.map +1 -1
- package/lib/commonjs/lib/web-streams.js +42 -0
- package/lib/commonjs/lib/web-streams.js.map +1 -0
- package/lib/commonjs/testing/device-fixture.js +70 -0
- package/lib/commonjs/testing/device-fixture.js.map +1 -0
- package/lib/commonjs/testing/expose.js +91 -0
- package/lib/commonjs/testing/expose.js.map +1 -0
- package/lib/commonjs/testing/harness.js +98 -0
- package/lib/commonjs/testing/harness.js.map +1 -0
- package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
- package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/index.js +100 -17
- package/lib/commonjs/testing/index.js.map +1 -1
- package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
- package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
- package/lib/commonjs/testing/serial-client.js +277 -0
- package/lib/commonjs/testing/serial-client.js.map +1 -0
- package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
- package/lib/commonjs/testing/simulated-device.js.map +1 -0
- package/lib/commonjs/testing/test-suite.js +142 -0
- package/lib/commonjs/testing/test-suite.js.map +1 -0
- package/lib/commonjs/transport.js +3 -3
- package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
- package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
- package/lib/commonjs/websocket/bridge.js +234 -0
- package/lib/commonjs/websocket/bridge.js.map +1 -0
- package/lib/commonjs/websocket/index.js +33 -0
- package/lib/commonjs/websocket/index.js.map +1 -0
- package/lib/commonjs/websocket/protocol.js +55 -0
- package/lib/commonjs/websocket/protocol.js.map +1 -0
- package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
- package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
- package/lib/typescript/src/UsbSerial.d.ts +1 -1
- package/lib/typescript/src/WebSerial.d.ts +7 -7
- package/lib/typescript/src/WebSerial.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/lib/event-target.d.ts +2 -0
- package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
- package/lib/typescript/src/lib/web-streams.d.ts +9 -0
- package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
- package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
- package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
- package/lib/typescript/src/testing/expose.d.ts +71 -0
- package/lib/typescript/src/testing/expose.d.ts.map +1 -0
- package/lib/typescript/src/testing/harness.d.ts +34 -0
- package/lib/typescript/src/testing/harness.d.ts.map +1 -0
- package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
- package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/index.d.ts +18 -8
- package/lib/typescript/src/testing/index.d.ts.map +1 -1
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
- package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
- package/lib/typescript/src/testing/serial-client.d.ts +62 -0
- package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
- package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
- package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
- package/lib/typescript/src/testing/test-suite.d.ts +75 -0
- package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
- package/lib/typescript/src/transport.d.ts +3 -3
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
- package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
- package/lib/typescript/src/websocket/bridge.d.ts +66 -0
- package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
- package/lib/typescript/src/websocket/index.d.ts +19 -0
- package/lib/typescript/src/websocket/index.d.ts.map +1 -0
- package/lib/typescript/src/websocket/protocol.d.ts +64 -0
- package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
- package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
- package/package.json +21 -3
- package/src/UsbSerial.ts +1 -1
- package/src/WebSerial.ts +134 -35
- package/src/index.ts +4 -1
- package/src/lib/event-target.ts +12 -0
- package/src/lib/web-streams.ts +43 -0
- package/src/testing/device-fixture.ts +150 -0
- package/src/testing/expose.ts +147 -0
- package/src/testing/harness.ts +124 -0
- package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
- package/src/testing/index.ts +69 -21
- package/src/testing/install-in-memory-serial-transport.ts +65 -0
- package/src/testing/serial-client.ts +313 -0
- package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
- package/src/testing/test-suite.ts +186 -0
- package/src/transport.ts +3 -3
- package/src/websocket/WebSocketSerialTransport.ts +796 -0
- package/src/websocket/bridge.ts +299 -0
- package/src/websocket/index.ts +38 -0
- package/src/websocket/protocol.ts +101 -0
- package/src/websocket/serial-device-bridge.ts +160 -0
- package/lib/commonjs/testing/install.js +0 -54
- package/lib/commonjs/testing/install.js.map +0 -1
- package/lib/commonjs/testing/serial-device.js.map +0 -1
- package/lib/commonjs/testing/virtual-serial.js.map +0 -1
- package/lib/typescript/src/testing/install.d.ts +0 -25
- package/lib/typescript/src/testing/install.d.ts.map +0 -1
- package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
- package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
- package/src/testing/install.ts +0 -65
package/android/build.gradle
CHANGED
|
@@ -44,6 +44,13 @@ android {
|
|
|
44
44
|
sourceCompatibility JavaVersion.VERSION_17
|
|
45
45
|
targetCompatibility JavaVersion.VERSION_17
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
testOptions {
|
|
49
|
+
unitTests {
|
|
50
|
+
includeAndroidResources = true
|
|
51
|
+
returnDefaultValues = true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
repositories {
|
|
@@ -59,4 +66,11 @@ dependencies {
|
|
|
59
66
|
implementation "com.github.mik3y:usb-serial-for-android:3.10.0"
|
|
60
67
|
// PortPickerActivity extends AppCompatActivity and uses the AppCompat dialog theme
|
|
61
68
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
|
69
|
+
|
|
70
|
+
// JVM unit tests. Robolectric provides an Android runtime so the TurboModule
|
|
71
|
+
// (which registers/unregisters BroadcastReceivers, talks to UsbManager, etc.)
|
|
72
|
+
// can be exercised without a device or emulator.
|
|
73
|
+
testImplementation "junit:junit:4.13.2"
|
|
74
|
+
testImplementation "org.robolectric:robolectric:4.14.1"
|
|
75
|
+
testImplementation "org.mockito:mockito-core:5.14.2"
|
|
62
76
|
}
|
|
@@ -31,9 +31,12 @@ import com.facebook.react.bridge.BaseActivityEventListener;
|
|
|
31
31
|
|
|
32
32
|
import java.util.ArrayList;
|
|
33
33
|
import java.util.EnumSet;
|
|
34
|
-
import java.util.HashMap;
|
|
35
34
|
import java.util.List;
|
|
36
35
|
import java.util.Map;
|
|
36
|
+
import java.util.concurrent.ConcurrentHashMap;
|
|
37
|
+
import java.util.concurrent.ExecutorService;
|
|
38
|
+
import java.util.concurrent.Executors;
|
|
39
|
+
import java.util.concurrent.atomic.AtomicInteger;
|
|
37
40
|
|
|
38
41
|
public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
39
42
|
|
|
@@ -43,17 +46,30 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
43
46
|
|
|
44
47
|
private final UsbManager usbManager;
|
|
45
48
|
|
|
49
|
+
// These maps are mutated from several threads — TurboModule calls (native
|
|
50
|
+
// modules thread), the broadcast receivers (main thread) and the
|
|
51
|
+
// SerialInputOutputManager listener (its own IO thread) — so they must be
|
|
52
|
+
// concurrent to avoid corruption / ConcurrentModificationException.
|
|
46
53
|
// key: "deviceId:portNumber"
|
|
47
|
-
private final Map<String, UsbSerialPort> openPorts = new
|
|
48
|
-
private final Map<String, UsbDeviceConnection> openConnections = new
|
|
49
|
-
private final Map<String, SerialInputOutputManager> ioManagers = new
|
|
54
|
+
private final Map<String, UsbSerialPort> openPorts = new ConcurrentHashMap<>();
|
|
55
|
+
private final Map<String, UsbDeviceConnection> openConnections = new ConcurrentHashMap<>();
|
|
56
|
+
private final Map<String, SerialInputOutputManager> ioManagers = new ConcurrentHashMap<>();
|
|
50
57
|
|
|
51
|
-
// key: requestCode
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
// key: requestCode. requestPermission() is reachable from both the native
|
|
59
|
+
// modules thread (direct JS call) and the main thread (onActivityResult ->
|
|
60
|
+
// requestPermission), so the counter must be atomic to avoid two callers
|
|
61
|
+
// colliding on a request code and clobbering each other's pending promise.
|
|
62
|
+
private final Map<Integer, Promise> pendingPermissions = new ConcurrentHashMap<>();
|
|
63
|
+
private final AtomicInteger nextRequestCode = new AtomicInteger(0);
|
|
64
|
+
|
|
65
|
+
// Resumes blocking USB work (open/setParameters) off the main thread when a
|
|
66
|
+
// permission grant arrives on the broadcast-receiver (main) thread.
|
|
67
|
+
private final ExecutorService backgroundExecutor = Executors.newSingleThreadExecutor();
|
|
54
68
|
|
|
55
69
|
private static final int PORT_PICKER_REQUEST_CODE = 0xAB8465;
|
|
56
|
-
|
|
70
|
+
// Written from showPortPicker (module thread), read/cleared from
|
|
71
|
+
// onActivityResult (main thread) — volatile for cross-thread visibility.
|
|
72
|
+
private volatile Promise pendingPortPickerPromise = null;
|
|
57
73
|
|
|
58
74
|
private final ActivityEventListener activityEventListener = new BaseActivityEventListener() {
|
|
59
75
|
@Override
|
|
@@ -177,7 +193,41 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
177
193
|
IntentFilter usbFilter = new IntentFilter();
|
|
178
194
|
usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
|
|
179
195
|
usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
|
|
180
|
-
|
|
196
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
197
|
+
reactContext.registerReceiver(usbStateReceiver, usbFilter, Context.RECEIVER_NOT_EXPORTED);
|
|
198
|
+
} else {
|
|
199
|
+
reactContext.registerReceiver(usbStateReceiver, usbFilter);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Release everything acquired in the constructor and during the module's
|
|
205
|
+
* life. Without this, every JS reload / context teardown leaks the three
|
|
206
|
+
* registered receivers (kept alive against a dead ReactApplicationContext)
|
|
207
|
+
* and any open USB ports — and stacks duplicate connect/disconnect/data
|
|
208
|
+
* events from the orphaned receivers.
|
|
209
|
+
*/
|
|
210
|
+
@Override
|
|
211
|
+
public void invalidate() {
|
|
212
|
+
ReactApplicationContext ctx = getReactApplicationContext();
|
|
213
|
+
try { ctx.unregisterReceiver(permissionReceiver); } catch (Exception ignored) {}
|
|
214
|
+
try { ctx.unregisterReceiver(usbStateReceiver); } catch (Exception ignored) {}
|
|
215
|
+
try { ctx.removeActivityEventListener(activityEventListener); } catch (Exception ignored) {}
|
|
216
|
+
|
|
217
|
+
for (String key : new ArrayList<>(ioManagers.keySet())) {
|
|
218
|
+
SerialInputOutputManager ioManager = ioManagers.remove(key);
|
|
219
|
+
try { if (ioManager != null) ioManager.stop(); } catch (Exception ignored) {}
|
|
220
|
+
}
|
|
221
|
+
for (String key : new ArrayList<>(openPorts.keySet())) {
|
|
222
|
+
UsbSerialPort port = openPorts.remove(key);
|
|
223
|
+
UsbDeviceConnection connection = openConnections.remove(key);
|
|
224
|
+
try { if (port != null) port.close(); } catch (Exception ignored) {}
|
|
225
|
+
try { if (connection != null) connection.close(); } catch (Exception ignored) {}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
backgroundExecutor.shutdown();
|
|
229
|
+
|
|
230
|
+
super.invalidate();
|
|
181
231
|
}
|
|
182
232
|
|
|
183
233
|
// Helper to avoid implementing all Promise methods in anonymous classes
|
|
@@ -294,7 +344,11 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
294
344
|
public void onResolve(Object value) {
|
|
295
345
|
Boolean granted = (Boolean) value;
|
|
296
346
|
if (granted != null && granted) {
|
|
297
|
-
|
|
347
|
+
// onResolve runs on the permission broadcast receiver's
|
|
348
|
+
// (main) thread; openDevice()/port.open() do blocking USB
|
|
349
|
+
// control transfers, so resume off the main thread.
|
|
350
|
+
backgroundExecutor.execute(() ->
|
|
351
|
+
open(fDeviceId, fPortNumber, fBaudRate, fDataBits, fStopBits, fParity, promise));
|
|
298
352
|
} else {
|
|
299
353
|
promise.reject("PERMISSION_DENIED", "USB permission denied");
|
|
300
354
|
}
|
|
@@ -640,6 +694,7 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
640
694
|
|
|
641
695
|
@Override
|
|
642
696
|
public void requestPermission(double deviceId, Promise promise) {
|
|
697
|
+
int requestCode = -1;
|
|
643
698
|
try {
|
|
644
699
|
UsbSerialDriver driver = findDriver((int) deviceId);
|
|
645
700
|
if (driver == null) {
|
|
@@ -651,7 +706,7 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
651
706
|
return;
|
|
652
707
|
}
|
|
653
708
|
|
|
654
|
-
|
|
709
|
+
requestCode = nextRequestCode.getAndIncrement();
|
|
655
710
|
pendingPermissions.put(requestCode, promise);
|
|
656
711
|
|
|
657
712
|
Intent intent = new Intent(ACTION_USB_PERMISSION);
|
|
@@ -671,6 +726,8 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
671
726
|
);
|
|
672
727
|
usbManager.requestPermission(driver.getDevice(), permissionIntent);
|
|
673
728
|
} catch (Exception e) {
|
|
729
|
+
// Don't leave an orphaned pending promise behind if dispatch failed.
|
|
730
|
+
if (requestCode != -1) pendingPermissions.remove(requestCode);
|
|
674
731
|
promise.reject("REQUEST_PERMISSION_ERROR", e.getMessage(), e);
|
|
675
732
|
}
|
|
676
733
|
}
|
|
@@ -720,6 +777,12 @@ public class NativeUsbSerialModule extends NativeUsbSerialSpec {
|
|
|
720
777
|
} catch (ActivityNotFoundException e) {
|
|
721
778
|
pendingPortPickerPromise = null;
|
|
722
779
|
promise.reject("ACTIVITY_NOT_FOUND", e.getMessage(), e);
|
|
780
|
+
} catch (Exception e) {
|
|
781
|
+
// Any other failure to launch must also clear the pending slot,
|
|
782
|
+
// otherwise the picker is wedged forever (every future call rejects
|
|
783
|
+
// with PICKER_ALREADY_OPEN) and this promise never settles.
|
|
784
|
+
pendingPortPickerPromise = null;
|
|
785
|
+
promise.reject("PICKER_LAUNCH_FAILED", e.getMessage(), e);
|
|
723
786
|
}
|
|
724
787
|
}
|
|
725
788
|
|
|
@@ -96,41 +96,64 @@ public class PortPickerActivity extends AppCompatActivity {
|
|
|
96
96
|
return drivers;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// Guard against mismatched array lengths to avoid ArrayIndexOutOfBoundsException
|
|
100
|
-
int filterCount = Math.min(filterVendorIds.length,
|
|
101
|
-
filterProductIds != null ? filterProductIds.length : 0);
|
|
102
|
-
|
|
103
99
|
List<UsbSerialDriver> filtered = new ArrayList<>();
|
|
104
100
|
for (UsbSerialDriver driver : drivers) {
|
|
105
101
|
int vid = driver.getDevice().getVendorId();
|
|
106
102
|
int pid = driver.getDevice().getProductId();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
boolean pidMatch = filterProductIds[i] == -1 || pid == filterProductIds[i];
|
|
110
|
-
if (vidMatch && pidMatch) {
|
|
111
|
-
filtered.add(driver);
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
103
|
+
if (matchesAnyFilter(vid, pid, filterVendorIds, filterProductIds)) {
|
|
104
|
+
filtered.add(driver);
|
|
114
105
|
}
|
|
115
106
|
}
|
|
116
107
|
return filtered;
|
|
117
108
|
}
|
|
118
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Whether a device with the given vendor/product id matches any of the
|
|
112
|
+
* parallel {@code vendorIds}/{@code productIds} filter arrays. A
|
|
113
|
+
* {@code productId} of -1 is a vendor-only wildcard. Mismatched array
|
|
114
|
+
* lengths are tolerated (the shorter length wins), and empty/no filters
|
|
115
|
+
* match everything. Pure and Android-free so it can be unit tested directly.
|
|
116
|
+
*/
|
|
117
|
+
static boolean matchesAnyFilter(int vid, int pid, int[] vendorIds, int[] productIds) {
|
|
118
|
+
if (vendorIds == null || vendorIds.length == 0) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
// Guard against mismatched array lengths to avoid ArrayIndexOutOfBoundsException
|
|
122
|
+
int filterCount = Math.min(vendorIds.length,
|
|
123
|
+
productIds != null ? productIds.length : 0);
|
|
124
|
+
for (int i = 0; i < filterCount; i++) {
|
|
125
|
+
boolean vidMatch = vid == vendorIds[i];
|
|
126
|
+
boolean pidMatch = productIds[i] == -1 || pid == productIds[i];
|
|
127
|
+
if (vidMatch && pidMatch) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
119
134
|
private void showPickerDialog() {
|
|
120
|
-
|
|
121
|
-
this,
|
|
122
|
-
getFilteredDrivers(),
|
|
123
|
-
resolve(titleSelectPort, DEFAULT_TITLE_SELECT_PORT),
|
|
124
|
-
resolve(titleNoPortsAvailable, DEFAULT_TITLE_NO_PORTS_AVAILABLE),
|
|
125
|
-
resolve(messageNoPortsAvailable, DEFAULT_MESSAGE_NO_PORTS_AVAILABLE)
|
|
126
|
-
);
|
|
127
|
-
dialog.show(getSupportFragmentManager(), TAG_PICKER_DIALOG);
|
|
135
|
+
new PortPickerDialogFragment().show(getSupportFragmentManager(), TAG_PICKER_DIALOG);
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
private static String resolve(String override, String defaultValue) {
|
|
131
139
|
return (override != null && !override.isEmpty()) ? override : defaultValue;
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
// Resolved labels, read by the (possibly recreated) dialog fragment. The
|
|
143
|
+
// backing fields are restored from getIntent() in onCreate, so these survive
|
|
144
|
+
// a configuration change such as rotation.
|
|
145
|
+
String resolvedTitleSelectPort() {
|
|
146
|
+
return resolve(titleSelectPort, DEFAULT_TITLE_SELECT_PORT);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
String resolvedTitleNoPortsAvailable() {
|
|
150
|
+
return resolve(titleNoPortsAvailable, DEFAULT_TITLE_NO_PORTS_AVAILABLE);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
String resolvedMessageNoPortsAvailable() {
|
|
154
|
+
return resolve(messageNoPortsAvailable, DEFAULT_MESSAGE_NO_PORTS_AVAILABLE);
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
/** Called by the DialogFragment when the user selects a port. */
|
|
135
158
|
void onPortSelected(UsbDevice device, int portNumber) {
|
|
136
159
|
Intent result = new Intent();
|
|
@@ -153,42 +176,28 @@ public class PortPickerActivity extends AppCompatActivity {
|
|
|
153
176
|
}
|
|
154
177
|
|
|
155
178
|
// -------------------------------------------------------------------------
|
|
156
|
-
// Static DialogFragment – survives configuration changes such as rotation
|
|
179
|
+
// Static DialogFragment – survives configuration changes such as rotation.
|
|
180
|
+
// It deliberately keeps NO transient state of its own: on every (re)creation
|
|
181
|
+
// it rebuilds the driver list and labels from the host activity, whose own
|
|
182
|
+
// state is restored from its Intent. Stashing the non-Parcelable driver list
|
|
183
|
+
// and an Activity reference on the fragment (as before) loses them on the
|
|
184
|
+
// recreation that rotation triggers, leaving an empty / dead dialog.
|
|
157
185
|
// -------------------------------------------------------------------------
|
|
158
186
|
public static class PortPickerDialogFragment extends DialogFragment {
|
|
159
187
|
|
|
160
|
-
// Not passed via Bundle argument because UsbSerialDriver is not Serializable/Parcelable
|
|
161
|
-
private List<UsbSerialDriver> drivers;
|
|
162
|
-
private PortPickerActivity host;
|
|
163
|
-
private String titleSelectPort;
|
|
164
|
-
private String titleNoPortsAvailable;
|
|
165
|
-
private String messageNoPortsAvailable;
|
|
166
|
-
|
|
167
|
-
static PortPickerDialogFragment newInstance(
|
|
168
|
-
PortPickerActivity host,
|
|
169
|
-
List<UsbSerialDriver> drivers,
|
|
170
|
-
String titleSelectPort,
|
|
171
|
-
String titleNoPortsAvailable,
|
|
172
|
-
String messageNoPortsAvailable) {
|
|
173
|
-
PortPickerDialogFragment f = new PortPickerDialogFragment();
|
|
174
|
-
f.host = host;
|
|
175
|
-
f.drivers = drivers;
|
|
176
|
-
f.titleSelectPort = titleSelectPort;
|
|
177
|
-
f.titleNoPortsAvailable = titleNoPortsAvailable;
|
|
178
|
-
f.messageNoPortsAvailable = messageNoPortsAvailable;
|
|
179
|
-
return f;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
188
|
@Override
|
|
183
189
|
public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
|
|
190
|
+
PortPickerActivity act = (PortPickerActivity) requireActivity();
|
|
184
191
|
Context ctx = requireContext();
|
|
185
192
|
|
|
193
|
+
List<UsbSerialDriver> drivers = act.getFilteredDrivers();
|
|
194
|
+
|
|
186
195
|
if (drivers == null || drivers.isEmpty()) {
|
|
187
196
|
return new AlertDialog.Builder(ctx)
|
|
188
|
-
.setTitle(
|
|
189
|
-
.setMessage(
|
|
190
|
-
.setPositiveButton(android.R.string.ok, (d, w) ->
|
|
191
|
-
.setOnCancelListener(d ->
|
|
197
|
+
.setTitle(act.resolvedTitleNoPortsAvailable())
|
|
198
|
+
.setMessage(act.resolvedMessageNoPortsAvailable())
|
|
199
|
+
.setPositiveButton(android.R.string.ok, (d, w) -> act.onPickerCancelled())
|
|
200
|
+
.setOnCancelListener(d -> act.onPickerCancelled())
|
|
192
201
|
.create();
|
|
193
202
|
}
|
|
194
203
|
|
|
@@ -215,21 +224,14 @@ public class PortPickerActivity extends AppCompatActivity {
|
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
return new AlertDialog.Builder(ctx)
|
|
218
|
-
.setTitle(
|
|
219
|
-
.setItems(labels.toArray(new String[0]), (d, which) ->
|
|
220
|
-
|
|
221
|
-
host.onPortSelected(
|
|
227
|
+
.setTitle(act.resolvedTitleSelectPort())
|
|
228
|
+
.setItems(labels.toArray(new String[0]), (d, which) ->
|
|
229
|
+
act.onPortSelected(
|
|
222
230
|
flatDrivers.get(which).getDevice(),
|
|
223
|
-
flatPortNumbers.get(which))
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
.setNegativeButton(android.R.string.cancel, (d, w) -> cancelAndFinish())
|
|
227
|
-
.setOnCancelListener(d -> cancelAndFinish())
|
|
231
|
+
flatPortNumbers.get(which)))
|
|
232
|
+
.setNegativeButton(android.R.string.cancel, (d, w) -> act.onPickerCancelled())
|
|
233
|
+
.setOnCancelListener(d -> act.onPickerCancelled())
|
|
228
234
|
.create();
|
|
229
235
|
}
|
|
230
|
-
|
|
231
|
-
private void cancelAndFinish() {
|
|
232
|
-
if (host != null) host.onPickerCancelled();
|
|
233
|
-
}
|
|
234
236
|
}
|
|
235
237
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* expose-serial-websocket — bridge a local serial port to a WebSocket so a
|
|
4
|
+
* browser / React Native app can drive it through `WebSocketSerialTransport`.
|
|
5
|
+
*
|
|
6
|
+
* This is a thin Node wrapper: argument parsing and the per-connection wiring
|
|
7
|
+
* live in the package's built `websocket/bridge` module (unit-tested with
|
|
8
|
+
* fakes); here we just supply the real `serialport` and `ws` objects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
let bridge;
|
|
12
|
+
try {
|
|
13
|
+
bridge = require('../lib/commonjs/websocket/bridge.js');
|
|
14
|
+
} catch (_e) {
|
|
15
|
+
console.error(
|
|
16
|
+
'Could not load the built bridge module. Build the package first ' +
|
|
17
|
+
'(npm run prepare) and try again.',
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const {attachBridge, parseBridgeArgs, USAGE} = bridge;
|
|
22
|
+
|
|
23
|
+
function requireOrExit(name) {
|
|
24
|
+
try {
|
|
25
|
+
return require(name);
|
|
26
|
+
} catch (_e) {
|
|
27
|
+
console.error(
|
|
28
|
+
`Missing optional dependency "${name}". Install it on this host:\n` +
|
|
29
|
+
` npm install ${name}\n`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function main() {
|
|
36
|
+
const args = parseBridgeArgs(process.argv.slice(2), process.env);
|
|
37
|
+
|
|
38
|
+
if (args.help) {
|
|
39
|
+
console.log(USAGE);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!args.port) {
|
|
43
|
+
console.error('Error: --port <path> is required.\n');
|
|
44
|
+
console.error(USAGE);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const {SerialPort} = requireOrExit('serialport');
|
|
49
|
+
const {WebSocketServer} = requireOrExit('ws');
|
|
50
|
+
|
|
51
|
+
const parseHexMaybe = value => {
|
|
52
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
const trimmed = value.trim();
|
|
57
|
+
if (!trimmed) return undefined;
|
|
58
|
+
const base = /^0x/i.test(trimmed) ? 16 : 16;
|
|
59
|
+
const parsed = Number.parseInt(trimmed.replace(/^0x/i, ''), base);
|
|
60
|
+
if (Number.isFinite(parsed)) {
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const normalizeInfo = info => {
|
|
68
|
+
if (!info || typeof info !== 'object') {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const usbVendorId = parseHexMaybe(info.vendorId ?? info.usbVendorId);
|
|
72
|
+
const usbProductId = parseHexMaybe(info.productId ?? info.usbProductId);
|
|
73
|
+
const serialNumber =
|
|
74
|
+
typeof info.serialNumber === 'string' ? info.serialNumber : undefined;
|
|
75
|
+
if (
|
|
76
|
+
usbVendorId === undefined &&
|
|
77
|
+
usbProductId === undefined &&
|
|
78
|
+
serialNumber === undefined
|
|
79
|
+
) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return {usbVendorId, usbProductId, serialNumber};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let cachedPortInfo = null;
|
|
86
|
+
|
|
87
|
+
const refreshPortInfo = async () => {
|
|
88
|
+
try {
|
|
89
|
+
const list = await SerialPort.list();
|
|
90
|
+
const match = list.find(p => p.path === args.port);
|
|
91
|
+
cachedPortInfo = normalizeInfo(match);
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore metadata refresh failures
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const serial = new SerialPort({path: args.port, baudRate: args.baudRate});
|
|
98
|
+
// Kick off metadata discovery in parallel; getPortInfo can still fall back to
|
|
99
|
+
// fields directly exposed by the opened serial instance.
|
|
100
|
+
void refreshPortInfo();
|
|
101
|
+
|
|
102
|
+
const getPortInfo = () => {
|
|
103
|
+
return (
|
|
104
|
+
cachedPortInfo ??
|
|
105
|
+
normalizeInfo({
|
|
106
|
+
vendorId: serial.vendorId,
|
|
107
|
+
productId: serial.productId,
|
|
108
|
+
serialNumber: serial.serialNumber,
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let serialClosed = false;
|
|
114
|
+
|
|
115
|
+
serial.on('error', err => {
|
|
116
|
+
console.error(`serial error: ${err.message}`);
|
|
117
|
+
if (
|
|
118
|
+
err.message.includes('Disconnected') ||
|
|
119
|
+
err.message.includes('No such file')
|
|
120
|
+
) {
|
|
121
|
+
serialClosed = true;
|
|
122
|
+
if (active) {
|
|
123
|
+
active.close(1011, 'Serial port disconnected');
|
|
124
|
+
active = null;
|
|
125
|
+
}
|
|
126
|
+
process.exit(1); // or attempt to reopen
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
serial.on('close', () => {
|
|
130
|
+
console.error('Serial port closed unexpectedly');
|
|
131
|
+
serialClosed = true;
|
|
132
|
+
if (active) {
|
|
133
|
+
active.close(1011, 'Serial port closed');
|
|
134
|
+
active = null;
|
|
135
|
+
}
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const wss = new WebSocketServer({host: args.host, port: args.wsPort});
|
|
140
|
+
let active = null;
|
|
141
|
+
let detachCurrent = null;
|
|
142
|
+
|
|
143
|
+
wss.on('connection', ws => {
|
|
144
|
+
if (active) {
|
|
145
|
+
ws.close(1013, 'serial port already in use');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!serial.isOpen || serialClosed) {
|
|
149
|
+
ws.close(1011, 'serial port not available');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
active = ws;
|
|
153
|
+
console.log('client connected');
|
|
154
|
+
const detach = attachBridge(serial, ws, {
|
|
155
|
+
log: m => console.error(m),
|
|
156
|
+
portInfo: getPortInfo,
|
|
157
|
+
});
|
|
158
|
+
detachCurrent = detach;
|
|
159
|
+
|
|
160
|
+
ws.on('close', () => {
|
|
161
|
+
if (detachCurrent) detachCurrent();
|
|
162
|
+
if (active === ws) active = null;
|
|
163
|
+
detachCurrent = null;
|
|
164
|
+
console.log('client disconnected');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
wss.on('listening', () => {
|
|
169
|
+
console.log(
|
|
170
|
+
`Serial port ${args.port} exposed on ws://${args.host}:${args.wsPort}`,
|
|
171
|
+
);
|
|
172
|
+
if (args.host === '0.0.0.0' || args.allowRemote) {
|
|
173
|
+
console.warn(
|
|
174
|
+
'⚠ Bound to a non-localhost address — your serial port is reachable ' +
|
|
175
|
+
'from the network. Only do this on trusted networks.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
wss.on('error', err => {
|
|
180
|
+
console.error(`WebSocket server error: ${err.message}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
let shuttingDown = false;
|
|
185
|
+
const shutdown = () => {
|
|
186
|
+
if (shuttingDown) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
shuttingDown = true;
|
|
190
|
+
console.log('\nshutting down…');
|
|
191
|
+
try {
|
|
192
|
+
wss.close();
|
|
193
|
+
} catch (_e) {}
|
|
194
|
+
try {
|
|
195
|
+
if (serial.isOpen) {
|
|
196
|
+
serial.close();
|
|
197
|
+
}
|
|
198
|
+
} catch (_e) {}
|
|
199
|
+
process.exit(0);
|
|
200
|
+
};
|
|
201
|
+
process.on('SIGINT', shutdown);
|
|
202
|
+
process.on('SIGTERM', shutdown);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
main();
|
|
@@ -161,7 +161,7 @@ function getUsbSerial() {
|
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
163
|
* Override the transport returned by {@link getUsbSerial}. Pass a
|
|
164
|
-
* {@link SerialTransport} (e.g.
|
|
164
|
+
* {@link SerialTransport} (e.g. an `InMemorySerialTransport`) to make the
|
|
165
165
|
* singleton `serial` instance — and any `new Serial()` created without an
|
|
166
166
|
* explicit transport — talk to it instead of real hardware. Pass `null` to
|
|
167
167
|
* clear the override.
|