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.
Files changed (112) hide show
  1. package/README.md +188 -117
  2. package/TESTING.md +417 -176
  3. package/android/build.gradle +14 -0
  4. package/android/src/main/java/dev/webserialapi/NativeUsbSerialModule.java +74 -11
  5. package/android/src/main/java/dev/webserialapi/PortPickerActivity.java +61 -59
  6. package/bin/expose-serial.js +205 -0
  7. package/lib/commonjs/UsbSerial.js +1 -1
  8. package/lib/commonjs/WebSerial.js +110 -26
  9. package/lib/commonjs/WebSerial.js.map +1 -1
  10. package/lib/commonjs/index.js +2 -2
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/lib/event-target.js +3 -1
  13. package/lib/commonjs/lib/event-target.js.map +1 -1
  14. package/lib/commonjs/lib/web-streams.js +42 -0
  15. package/lib/commonjs/lib/web-streams.js.map +1 -0
  16. package/lib/commonjs/testing/device-fixture.js +70 -0
  17. package/lib/commonjs/testing/device-fixture.js.map +1 -0
  18. package/lib/commonjs/testing/expose.js +91 -0
  19. package/lib/commonjs/testing/expose.js.map +1 -0
  20. package/lib/commonjs/testing/harness.js +98 -0
  21. package/lib/commonjs/testing/harness.js.map +1 -0
  22. package/lib/commonjs/testing/{virtual-serial.js → in-memory-serial-transport.js} +66 -28
  23. package/lib/commonjs/testing/in-memory-serial-transport.js.map +1 -0
  24. package/lib/commonjs/testing/index.js +100 -17
  25. package/lib/commonjs/testing/index.js.map +1 -1
  26. package/lib/commonjs/testing/install-in-memory-serial-transport.js +54 -0
  27. package/lib/commonjs/testing/install-in-memory-serial-transport.js.map +1 -0
  28. package/lib/commonjs/testing/serial-client.js +277 -0
  29. package/lib/commonjs/testing/serial-client.js.map +1 -0
  30. package/lib/commonjs/testing/{serial-device.js → simulated-device.js} +17 -17
  31. package/lib/commonjs/testing/simulated-device.js.map +1 -0
  32. package/lib/commonjs/testing/test-suite.js +142 -0
  33. package/lib/commonjs/testing/test-suite.js.map +1 -0
  34. package/lib/commonjs/transport.js +3 -3
  35. package/lib/commonjs/websocket/WebSocketSerialTransport.js +659 -0
  36. package/lib/commonjs/websocket/WebSocketSerialTransport.js.map +1 -0
  37. package/lib/commonjs/websocket/bridge.js +234 -0
  38. package/lib/commonjs/websocket/bridge.js.map +1 -0
  39. package/lib/commonjs/websocket/index.js +33 -0
  40. package/lib/commonjs/websocket/index.js.map +1 -0
  41. package/lib/commonjs/websocket/protocol.js +55 -0
  42. package/lib/commonjs/websocket/protocol.js.map +1 -0
  43. package/lib/commonjs/websocket/serial-device-bridge.js +130 -0
  44. package/lib/commonjs/websocket/serial-device-bridge.js.map +1 -0
  45. package/lib/typescript/src/UsbSerial.d.ts +1 -1
  46. package/lib/typescript/src/WebSerial.d.ts +7 -7
  47. package/lib/typescript/src/WebSerial.d.ts.map +1 -1
  48. package/lib/typescript/src/index.d.ts +1 -1
  49. package/lib/typescript/src/index.d.ts.map +1 -1
  50. package/lib/typescript/src/lib/event-target.d.ts +2 -0
  51. package/lib/typescript/src/lib/event-target.d.ts.map +1 -1
  52. package/lib/typescript/src/lib/web-streams.d.ts +9 -0
  53. package/lib/typescript/src/lib/web-streams.d.ts.map +1 -0
  54. package/lib/typescript/src/testing/device-fixture.d.ts +70 -0
  55. package/lib/typescript/src/testing/device-fixture.d.ts.map +1 -0
  56. package/lib/typescript/src/testing/expose.d.ts +71 -0
  57. package/lib/typescript/src/testing/expose.d.ts.map +1 -0
  58. package/lib/typescript/src/testing/harness.d.ts +34 -0
  59. package/lib/typescript/src/testing/harness.d.ts.map +1 -0
  60. package/lib/typescript/src/testing/{virtual-serial.d.ts → in-memory-serial-transport.d.ts} +37 -26
  61. package/lib/typescript/src/testing/in-memory-serial-transport.d.ts.map +1 -0
  62. package/lib/typescript/src/testing/index.d.ts +18 -8
  63. package/lib/typescript/src/testing/index.d.ts.map +1 -1
  64. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts +25 -0
  65. package/lib/typescript/src/testing/install-in-memory-serial-transport.d.ts.map +1 -0
  66. package/lib/typescript/src/testing/serial-client.d.ts +62 -0
  67. package/lib/typescript/src/testing/serial-client.d.ts.map +1 -0
  68. package/lib/typescript/src/testing/{serial-device.d.ts → simulated-device.d.ts} +23 -23
  69. package/lib/typescript/src/testing/simulated-device.d.ts.map +1 -0
  70. package/lib/typescript/src/testing/test-suite.d.ts +75 -0
  71. package/lib/typescript/src/testing/test-suite.d.ts.map +1 -0
  72. package/lib/typescript/src/transport.d.ts +3 -3
  73. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts +111 -0
  74. package/lib/typescript/src/websocket/WebSocketSerialTransport.d.ts.map +1 -0
  75. package/lib/typescript/src/websocket/bridge.d.ts +66 -0
  76. package/lib/typescript/src/websocket/bridge.d.ts.map +1 -0
  77. package/lib/typescript/src/websocket/index.d.ts +19 -0
  78. package/lib/typescript/src/websocket/index.d.ts.map +1 -0
  79. package/lib/typescript/src/websocket/protocol.d.ts +64 -0
  80. package/lib/typescript/src/websocket/protocol.d.ts.map +1 -0
  81. package/lib/typescript/src/websocket/serial-device-bridge.d.ts +32 -0
  82. package/lib/typescript/src/websocket/serial-device-bridge.d.ts.map +1 -0
  83. package/package.json +21 -3
  84. package/src/UsbSerial.ts +1 -1
  85. package/src/WebSerial.ts +134 -35
  86. package/src/index.ts +4 -1
  87. package/src/lib/event-target.ts +12 -0
  88. package/src/lib/web-streams.ts +43 -0
  89. package/src/testing/device-fixture.ts +150 -0
  90. package/src/testing/expose.ts +147 -0
  91. package/src/testing/harness.ts +124 -0
  92. package/src/testing/{virtual-serial.ts → in-memory-serial-transport.ts} +95 -56
  93. package/src/testing/index.ts +69 -21
  94. package/src/testing/install-in-memory-serial-transport.ts +65 -0
  95. package/src/testing/serial-client.ts +313 -0
  96. package/src/testing/{serial-device.ts → simulated-device.ts} +23 -23
  97. package/src/testing/test-suite.ts +186 -0
  98. package/src/transport.ts +3 -3
  99. package/src/websocket/WebSocketSerialTransport.ts +796 -0
  100. package/src/websocket/bridge.ts +299 -0
  101. package/src/websocket/index.ts +38 -0
  102. package/src/websocket/protocol.ts +101 -0
  103. package/src/websocket/serial-device-bridge.ts +160 -0
  104. package/lib/commonjs/testing/install.js +0 -54
  105. package/lib/commonjs/testing/install.js.map +0 -1
  106. package/lib/commonjs/testing/serial-device.js.map +0 -1
  107. package/lib/commonjs/testing/virtual-serial.js.map +0 -1
  108. package/lib/typescript/src/testing/install.d.ts +0 -25
  109. package/lib/typescript/src/testing/install.d.ts.map +0 -1
  110. package/lib/typescript/src/testing/serial-device.d.ts.map +0 -1
  111. package/lib/typescript/src/testing/virtual-serial.d.ts.map +0 -1
  112. package/src/testing/install.ts +0 -65
@@ -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 HashMap<>();
48
- private final Map<String, UsbDeviceConnection> openConnections = new HashMap<>();
49
- private final Map<String, SerialInputOutputManager> ioManagers = new HashMap<>();
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
- private final Map<Integer, Promise> pendingPermissions = new HashMap<>();
53
- private int nextRequestCode = 0;
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
- private Promise pendingPortPickerPromise = null;
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
- reactContext.registerReceiver(usbStateReceiver, usbFilter);
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
- open(fDeviceId, fPortNumber, fBaudRate, fDataBits, fStopBits, fParity, promise);
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
- int requestCode = nextRequestCode++;
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
- for (int i = 0; i < filterCount; i++) {
108
- boolean vidMatch = vid == filterVendorIds[i];
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
- PortPickerDialogFragment dialog = PortPickerDialogFragment.newInstance(
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(titleNoPortsAvailable)
189
- .setMessage(messageNoPortsAvailable)
190
- .setPositiveButton(android.R.string.ok, (d, w) -> cancelAndFinish())
191
- .setOnCancelListener(d -> cancelAndFinish())
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(titleSelectPort)
219
- .setItems(labels.toArray(new String[0]), (d, which) -> {
220
- if (host != null) {
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. a `VirtualSerialTransport`) to make the
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.